docs: WASM SSR-handoff seam audit and remediation plan

This commit is contained in:
daniel-c-harvey
2026-06-07 10:09:40 -04:00
parent 86d70c1af6
commit ba31e124f2
+96
View File
@@ -0,0 +1,96 @@
# 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 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.
### 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
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`, `DeepDrftHero` stat row, genre cards, feature cards on `Home.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.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 animations** — `circle-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.