Files

13 KiB
Raw Permalink Blame History

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)

Status: RESOLVED

  • Files: DeepDrftPublic.Client/Controls/DeepDrftHero.razor (markup), DeepDrftPublic.Client/Controls/DeepDrftHero.razor.css (lines 210).
  • What happens: Five elements carry class="fade-up". The CSS is opacity: 0; animation: fade-up 0.8s ease forwards; — an unconditional entrance animation that runs whenever the element mounts. DeepDrftHero sits on Home.razor (@page "/"), which is InteractiveAuto. 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.
  • Resolution: DeepDrftHero.razor updated to gate the fade-up animation class on !RendererInfo.IsInteractive. The SSR prerender pass renders with the class (animation plays on first paint); the WASM hydration pass renders without it (elements land in settled state, no replay). Approach used: Option A from remediation plan. Build clean, zero errors. Commit: fix: gate hero fade-up on SSR pass only to stop double-fire on WASM hydration.

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: NowPlayingCard renders Player?.CurrentTrack?.TrackName ?? "Nothing playing" and branches the waveform on Player?.IsLoaded. The player service (StreamingAudioPlayerService) is created fresh per render context by AudioPlayerProvider.OnInitialized and 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 PersistTracks via RegisterOnPersisting, restores PagedResult<TrackDto> from key "tracks-page" via TryTakeFromJson before issuing any fetch, and only calls SetPage on a miss. Skeleton (markup lines 2642 of .razor) is suppressed on the WASM pass because ViewModel.Page is 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 TrackDto under key "track-detail". Restores ViewModel.Track + sets IsLoading = false before fetch; only calls ViewModel.Load(EntryKey) on a miss. Skeleton block (.razor lines 621) 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 darkMode cookie during prerender, seeds DarkModeSettings, round-trips through PersistentComponentState under 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 _stagedKey change 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

Status: DONE

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); possibly DeepDrftPublic.Client/Controls/DeepDrftHero.razor (add the gating class/attribute hook) and/or a one-line marker in DeepDrftPublic/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 is InteractiveServer (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's StateChanged multicast and re-render off live runtime state. They hold no prerender-fetched data to persist — the player cannot be live during prerender (gesture-gated). Their OnParametersSet/OnAfterRender subscription logic is correct fencing, not a missing persist. Leave them.
  • NowPlayingStats stat row on Home.razor. Live data — fetches HomeStatsDto via IStatsDataService/StatsClient (GET api/stats/home) and bridges the prerender→WASM fetch through PersistentComponentState (same Mode A seam as S1/S2). On the WASM pass the persisted HomeStatsDto is restored before any fetch, so there is no skeleton-to-content swap or re-fetch. No entrance animation, no flip. Already correct; do not touch.
  • DeepDrftHero genre cards, feature cards on Home.razor. Fully static markup (hard-coded copy, no fetch). Same content on both passes → no flip. No entrance animation (Home.razor.css has only steady-state transition: rules on hover/theme, lines 127/145/199/368/489/509 — those fire on interaction, not mount, and are correct).
  • Infinite/steady-state CSS animationscircle-deco pulse-ring (NowPlaying.razor.css), waveform wave-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)

  1. 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.
  2. 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.