diff --git a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs index ec4b824..389fabb 100644 --- a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs +++ b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs @@ -19,16 +19,40 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable private PersistingComponentStateSubscription _persistingSubscription; + // The release id the ViewModel currently holds. Tracks param-only navigations (e.g. + // /mixes/5 -> /mixes/8) which reuse this component instance and fire OnParametersSet + // without re-running OnInitialized — without this, the page would keep the prior + // release's track and Play would stream the wrong audio. + private long _loadedId; + private bool _loaded; + // Distinct keys per medium so a Session restore never lands on a Mix page. protected abstract string PersistKey { get; } - protected override async Task OnInitializedAsync() + protected override void OnInitialized() + => _persistingSubscription = PersistentState.RegisterOnPersisting(Persist); + + protected override async Task OnParametersSetAsync() { - _persistingSubscription = PersistentState.RegisterOnPersisting(Persist); + // Re-run whenever the route id changes. Component instances are reused across + // same-template navigations, so the load decision must live here, not in + // 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. - if (PersistentState.TryTakeFromJson(PersistKey, out var restored) && restored?.Release is not null) + // 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). + if (PersistentState.TryTakeFromJson(PersistKey, out var restored) + && restored?.Release is not null + && restored.Release.Id == Id) { ViewModel.Restore(restored.Release, restored.Track); } diff --git a/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs index 23280e2..778f5be 100644 --- a/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs +++ b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs @@ -14,17 +14,41 @@ public partial class TrackDetail : ComponentBase, IDisposable private PersistingComponentStateSubscription _persistingSubscription; - protected override async Task OnInitializedAsync() - { + // The entry key the ViewModel currently holds. Tracks param-only navigations + // (e.g. /track/A -> /track/B) which reuse this component instance and fire + // OnParametersSet without re-running OnInitialized — without this, the page keeps + // the prior track and Play streams the wrong audio. + private string? _loadedEntryKey; + + protected override void OnInitialized() // Carry the prerendered track across the prerender -> interactive (WASM) seam. // Without this, the WASM pass gets a fresh scoped ViewModel, re-renders the // skeleton, and re-fetches. Mirror the TracksView bridge: persist on the way // out of prerender, restore on the interactive pass, and only fetch on a miss. - _persistingSubscription = PersistentState.RegisterOnPersisting(PersistTrack); + => _persistingSubscription = PersistentState.RegisterOnPersisting(PersistTrack); - if (PersistentState.TryTakeFromJson(PersistKey, out var restored) && restored is not null) + protected override async Task OnParametersSetAsync() + { + // Re-run whenever the route key changes. Component instances are reused across + // same-template navigations, so the load decision must live here, not in + // 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) + && restored is not null + && restored.EntryKey == EntryKey) { ViewModel.Track = restored; + ViewModel.NotFound = false; ViewModel.IsLoading = false; } else