From 7d23c0654bab5053b06dfa15a38a48b748ae60f9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 12:55:15 -0400 Subject: [PATCH] fix(detail): capture guard fields before await to close re-entrancy window in OnParametersSetAsync --- DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs | 11 ++++++++--- DeepDrftPublic.Client/Pages/TrackDetail.razor.cs | 9 +++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs index e3e4615..389fabb 100644 --- a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs +++ b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs @@ -39,6 +39,14 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable // OnInitialized (which fires once per instance). if (_loaded && _loadedId == Id) return; + // Capture the id synchronously before any await so that a re-entrant call + // (rapid navigation or a re-render that changes Id while Load is in flight) + // sees the correct guard state. Without this, a second OnParametersSetAsync + // for the same Id would bypass the guard above and start a second Load, + // causing two ViewModel.Load calls to race on the single scoped instance. + _loadedId = Id; + _loaded = true; + // The bridged payload carries both the release and its resolved track so the interactive // pass renders identically without a second round-trip. Guard on the id: a payload for a // different release must not seed this page (stale-bridge bleed across navigation). @@ -52,9 +60,6 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable { await ViewModel.Load(Id); } - - _loadedId = Id; - _loaded = true; } private Task Persist() diff --git a/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs index 3d39cb0..778f5be 100644 --- a/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs +++ b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs @@ -34,6 +34,13 @@ public partial class TrackDetail : ComponentBase, IDisposable // OnInitialized (which fires once per instance). if (_loadedEntryKey == EntryKey) return; + // Capture the key synchronously before any await so that a re-entrant call + // (rapid navigation or a re-render that changes EntryKey while Load is in flight) + // sees the correct guard state. Without this, a second OnParametersSetAsync + // for the same EntryKey would bypass the guard above and start a second Load, + // causing two ViewModel.Load calls to race on the single scoped instance. + _loadedEntryKey = EntryKey; + // Guard the bridge on the key: a payload for a different track must not seed this // page (stale-bridge bleed across navigation). if (PersistentState.TryTakeFromJson(PersistKey, out var restored) @@ -48,8 +55,6 @@ public partial class TrackDetail : ComponentBase, IDisposable { await ViewModel.Load(EntryKey); } - - _loadedEntryKey = EntryKey; } private Task PersistTrack()