12 KiB
WASM Seam Audit — SSR→WASM State Persistence
Scope: Every interactive component on the DeepDrftPublic public site (root render mode InteractiveAuto, set in DeepDrftPublic/Components/App.razor) where prerendered or server-rendered state is not carried across the SSR→WASM handoff, causing visible regressions — animation replays, data re-fetches, UI flash.
Audit date: 2026-06-07. Audited DeepDrftPublic.Client (all pages + controls) and DeepDrftShared.Client (shared components) against their data-fetch and CSS-animation paths.
The mechanism (why this class of bug exists): With InteractiveAuto, every interactive component runs its full lifecycle twice — once during SSR prerender, once when the WASM bundle boots. On the WASM pass the component gets a fresh scoped DI instance with empty state. If it re-fetches in OnInitialized(Async), the rendered output flips (skeleton → content) seconds after first paint, the DOM nodes are recreated, and any CSS entrance animation bound to those nodes replays. Two distinct failure modes fall out of this:
- Mode A — data re-fetch: component fetches on init; WASM pass has no data, re-renders skeleton, re-fetches, swaps DOM. Fix: bridge the result via
PersistentComponentState(persist on prerender exit, restore-before-fetch on the interactive pass). - Mode B — animation replay: a component with a pure CSS entrance animation (
opacity 0 → 1, slide-up) has its DOM recreated on the WASM pass, so the animation fires a second time even when no data is involved. Fix is NOT PersistentComponentState — there is no state to persist. The fix is to stop the animation from re-firing on the interactive remount (see Remediation R1).
1. Confirmed gaps
G1 — DeepDrftHero fade-up animation replays on home-page WASM boot (Mode B)
- Files:
DeepDrftPublic.Client/Controls/DeepDrftHero.razor(markup),DeepDrftPublic.Client/Controls/DeepDrftHero.razor.css(lines 2–10). - What happens: Five elements carry
class="fade-up". The CSS isopacity: 0; animation: fade-up 0.8s ease forwards;— an unconditional entrance animation that runs whenever the element mounts.DeepDrftHerosits onHome.razor(@page "/"), which isInteractiveAuto. On the WASM remount the hero subtree is recreated and the whole headline block fades up from transparent a second time, seconds after the page was already visible and settled. - Symptom: The hero title / eyebrow / subtitle / desc / CTA visibly re-fade after WASM loads — the canonical "it animates, then a beat later animates again" regression. This is the home-page instance of the symptom the audit was opened against.
- User-visible impact: High. It is the first thing every visitor sees, above the fold, on the site root. Most-trafficked page, most-noticeable element.
- Note: This is not a missing PersistentComponentState — the hero fetches nothing. Persisting state will not help. See R1.
G2 — NowPlaying waveform/placeholder swap on WASM boot (Mode A-adjacent, lower severity)
- Files:
DeepDrftPublic.Client/Controls/NowPlaying.razor,DeepDrftPublic.Client/Controls/NowPlayingCard.razor. - What happens:
NowPlayingCardrendersPlayer?.CurrentTrack?.TrackName ?? "Nothing playing"and branches the waveform onPlayer?.IsLoaded. The player service (StreamingAudioPlayerService) is created fresh per render context byAudioPlayerProvider.OnInitializedand is not carried across the seam — by design, because the audio context requires a user gesture and cannot be live during prerender. On a cold home load with nothing playing, prerender and WASM agree ("Nothing playing", placeholder), so there is no visible flip. - Symptom: None on cold load. A flip is only possible if a track were already loaded during prerender — which cannot happen, because audio is gesture-gated and the provider is deliberately not eagerly initialized.
- User-visible impact: Low / effectively none today. Listed for completeness so staff-engineer does not "fix" it: the absence of a persist here is correct given the gesture-gated audio model. Leave it. (Cross-reference: this is the same reasoning that makes the player provider intentionally non-persisted — see §2 and §4.)
2. Existing seams that work — DO NOT TOUCH
These already bridge the seam correctly. Re-implementing or "tidying" them risks reintroducing the bug.
S1 — TracksView (/tracks) ✓
- File:
DeepDrftPublic.Client/Pages/TracksView.razor.cs. - Registers
PersistTracksviaRegisterOnPersisting, restoresPagedResult<TrackDto>from key"tracks-page"viaTryTakeFromJsonbefore issuing any fetch, and only callsSetPageon a miss. Skeleton (markup lines 26–42 of.razor) is suppressed on the WASM pass becauseViewModel.Pageis already populated from the restore. This is the reference implementation the memory entry describes.
S2 — TrackDetail (/track/{EntryKey}) ✓
- File:
DeepDrftPublic.Client/Pages/TrackDetail.razor.cs. - Mirrors S1 for a single
TrackDtounder key"track-detail". RestoresViewModel.Track+ setsIsLoading = falsebefore fetch; only callsViewModel.Load(EntryKey)on a miss. Skeleton block (.razorlines 6–21) is correctly skipped on restore.
S3 — Dark mode bridge ✓
- Files:
DeepDrftPublic/Services/DarkModeService.cs(server seed),DeepDrftPublic.Client/Layout/MainLayout.razor+EmbedLayout.razor(restore),DeepDrftPublic.Client/Common/DarkModeSettings.cs([PersistentState]). - Server reads the
darkModecookie during prerender, seedsDarkModeSettings, round-trips throughPersistentComponentStateunder key"darkMode". Both layouts restore-before-default. This is the original canonical bridge; the data seams (S1/S2) were modeled on it.
S4 — FramePlayer (/FramePlayer, embed) ✓ (correct by a different mechanism)
- File:
DeepDrftPublic.Client/Pages/FramePlayer.razor. - Fetches a track in
OnParametersSetAsync, guarded by a_stagedKeychange check so it does not re-fetch on every parameter set. It does not use PersistentComponentState, and that is acceptable here: it only stages the track (StageTrack) — no audio context, no streaming, no entrance animation, no skeleton-to-content swap. The worst case on the WASM pass is one idempotent re-fetch of lightweight metadata with no visible flip. Not a priority. (If the embed ever grows a skeleton or entrance animation, revisit — but today it is fine.)
3. Remediation plan (highest impact first)
R1 — Stop DeepDrftHero fade-up from replaying on the interactive remount ⟵ only real fix needed
Addresses G1. PersistentComponentState does not apply (no data). The animation must not re-fire when the hero subtree is recreated on the WASM pass. Three viable approaches, in order of preference:
Option A (recommended) — gate the animation to first paint via a one-shot CSS marker.
Run the entrance animation only on the server-prerendered DOM, and have the interactive remount land in the already-settled end state (no animation). Concretely: drive fade-up off a class/attribute that is present on the prerendered markup but is treated as already-complete on the interactive pass — e.g. an animation-delay/forwards end-state that the remount inherits, or a top-level data-hydrated attribute set once (by the existing audio bootstrap script or a tiny inline script) that flips .fade-up to its terminal state (opacity:1; animation:none;). Because the home page DOM is reused/recreated under the same selector, the remounted nodes match the "hydrated" rule and skip the animation.
- File to change:
DeepDrftPublic.Client/Controls/DeepDrftHero.razor.css(add the hydrated/terminal-state rule); possiblyDeepDrftPublic.Client/Controls/DeepDrftHero.razor(add the gating class/attribute hook) and/or a one-line marker inDeepDrftPublic/Components/App.razor's existing module script. - Acceptance: On a cold load of
/, the hero fades up exactly once (during/just after first paint) and does not re-fade when the WASM bundle finishes booting. Verify with throttled network so the WASM pass is clearly separated in time.
Option B — prefers-reduced-motion-style global "animations already played" flag.
A single session/runtime flag (set after first interactive render) that disables all fade-up-family entrance animations. Heavier than needed for one component, but worth it if more entrance animations get added (see Open Questions). Same acceptance criterion as A, applied site-wide.
Option C — move the animation trigger to an explicit post-hydration hook.
Start hero elements at their terminal state and add .fade-up only after the first interactive render (e.g. an OnAfterRenderAsync(firstRender) that adds the class once via a field-backed CSS class), so the animation is owned by the WASM pass alone and never double-fires.
- Trade-off: Loses the entrance animation on the prerendered paint (it would only play after WASM boots, defeating the point of prerender for above-the-fold motion). Recommend against unless A/B prove fiddly.
Recommendation: Option A. Smallest change, preserves the prerender-time entrance animation (which is the whole point), kills the replay.
R2 — No action: confirm G2 / NowPlaying is intentionally non-persisted
Addresses G2. No code change. The remediation is a one-line code comment (staff-engineer's call whether to add) noting that the player provider is deliberately not bridged because audio is gesture-gated. Listed so the audit's "fix everything" reflex does not add a useless persist here.
- Acceptance: No regression introduced; player still requires a user gesture to start; cold home load shows "Nothing playing" on both passes with no flip.
4. Out of scope
DeepDrftManager(CMS). Render mode isInteractiveServer(server-rendered, single lifecycle — no SSR→WASM handoff). The whole class of bug does not exist there. Not audited beyond confirming the render mode.DeepDrftData,DeepDrftContent,DeepDrftAPI. Server-side only; never reach WASM. No client lifecycle.AudioPlayerProvider/StreamingAudioPlayerService/AudioPlayerBar/SpectrumVisualizer/PlayStateIcon/WaveformSeeker. These subscribe to the player'sStateChangedmulticast and re-render off live runtime state. They hold no prerender-fetched data to persist — the player cannot be live during prerender (gesture-gated). TheirOnParametersSet/OnAfterRendersubscription logic is correct fencing, not a missing persist. Leave them.NowPlayingStats,DeepDrftHerostat row, genre cards, feature cards onHome.razor. Fully static markup (hard-coded copy, no fetch). Same content on both passes → no flip. The only animated one is the hero (G1/R1); the rest have no entrance animation (Home.razor.csshas only steady-statetransition:rules on hover/theme, lines 127/145/199/368/489/509 — those fire on interaction, not mount, and are correct).- Infinite/steady-state CSS animations —
circle-decopulse-ring (NowPlaying.razor.css), waveformwave-dance/blink(NowPlayingCard.razor.css), spectrum bars. These loop continuously by design; a remount restarting their loop is imperceptible (they never "settle"). Not entrance animations, not a regression. - CSS / JS asset staleness across deploys. Separate concern, already handled:
@Assets[...]fingerprints CSS (App.razor),<ImportMap />fingerprints the audio JS module graph. Do not conflate with the seam work.
Open questions (for Daniel)
- Is the hero the only entrance animation we want, or a pattern we'll repeat? If more
fade-up-style entrances are coming to the home page sections (genre cards, feature cards staggering in), R1 Option B (a global "already-hydrated, skip entrance animations" flag) pays for itself and is worth doing now instead of patching one component. If the hero is a one-off, Option A is the lighter call. This decision changes which remediation to scope. - Should the entrance animation play at all on a client-side nav into
/(not a cold load)? On in-app navigation the hero genuinely mounts for the first time on the WASM side, and a single fade-up there is arguably desirable. Option A naturally preserves that (it only suppresses the double fire on the prerender→interactive seam); a naive global "never animate after hydration" flag (a sloppy Option B) would kill it. Worth stating the intended behavior so the fix targets the seam, not all animation.