Files

103 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.