Skip to content

fix(router): PPR resume theme fidelity — replay the capture's initialTheme, re-sync from cookie post-mount - #646

Merged
ivogt merged 1 commit into
mainfrom
fix/ppr-theme-capture
Jul 3, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/ppr-theme-capture

Conversation

@ivogt

@ivogt ivogt commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

User-reported on PPR'd blog routes: a dark-theme visitor hitting a warmed shell got the light page, and the theme toggle did nothing. Root cause: initialTheme is per-request metadata (reqCtx.theme, the visitor's cookie) feeding ThemeProvider inside SsrRoot — it is NOT part of the cached segments, so PPR's "shell identity holds by construction" argument didn't cover it. A capture triggered by a default-light request froze a light-rendered prelude; a dark visitor's resume tail then rendered a divergent tree above the holes. React resume requires that tree to match the prerendered one, so stitching/hydration broke — wrong theme AND dead interactivity in one failure.

Before vs after

BEFORE (broken on a HIT with a differing visitor theme)
  capture:  ThemeProvider initialTheme = capturer's theme (light) -> frozen prelude
  serve:    tail renders initialTheme = visitor's theme (dark)    -> tree diverges
  result:   resume/hydration breaks -> light shell sticks, toggle dead

AFTER
  capture:  entry.initialTheme = the theme the capture rendered with
  serve:    tail replays entry.initialTheme -> resume tree matches the prelude
  visitor:  FOUC script applies the cookie theme PRE-PAINT;
            ThemeProvider re-syncs state from the cookie POST-MOUNT
  result:   dark class applies and stays, hydration clean, toggle works

Consumer-visible behavior

Nothing to configure. On a HIT whose visitor theme differs from the capture's:

Moment What the visitor sees
first paint correct (cookie) theme — the FOUC script in the captured head runs at parse time
hydration clean — the resume tree matches the frozen prelude by construction
post-mount ThemeProvider state re-synced from the cookie; toggle and useTheme readers correct
edge a themed component rendered IN the shell may briefly show the captured theme's markup until the post-mount re-sync (documented in /ppr)

The re-sync only honors an explicitly stored cookie/localStorage value — the defaultTheme fallback never overrides a server-provided initialTheme for a visitor who never chose a theme.

Code changes

  • cache/types.ts — ShellCacheEntry.initialTheme?: string (with the why in the docblock).
  • rsc/shell-capture.ts — captureAndStoreShell records the derived capture context's reqCtx.theme into the entry.
  • rsc/rsc-rendering.ts — serveShellHit's tail overrides payload.metadata.initialTheme with the stored value (the payload feeds BOTH the SSR resume tree and client hydration, so all three trees — prelude, resume, hydration — agree).
  • theme/ThemeProvider.tsx — post-mount effect re-syncs state (and the document attribute) from an explicit cookie/localStorage value.
  • Design doc: "Theme fidelity on resume (scar tissue)" section; /ppr skill pitfall entry.

Test plan

  • Unit: capture stores initialTheme (shell-capture.test.ts); HIT tail replays the captured theme while the visitor's reqCtx.theme differs (rsc-rendering-shell-ppr.test.ts); ThemeProvider cookie re-sync + no-op-without-explicit-value (theme-provider.test.tsx, localStorage-isolated).
  • E2e dev + production (test-app, which has themeConfig): warm /shell-cache with no cookie (capture bakes light), visit with a theme=dark cookie — dark class applies and STAYS after hydration, zero hydration errors, counter interactive. This pins the exact reported regression.
  • pnpm run test:unit:all (all packages), pnpm build-router, repo typecheck, lint, format, check:e2e-bucketing, check:docs-api.

Notes

  • Base is feat(router): PPR opt-in via the ppr path option, integral serving, render-defined holes #645 (feat/ppr-cache-opt-in) — merge after it.

  • Old shell entries without initialTheme replay undefined, matching captures made before theme recording — self-consistent; entries roll over via TTL/reactVersion anyway.

  • cloudflare-basic /blog + /blog/:slug stay NON-ppr on this branch (revised after CI): the blog's ring-3 cache({ ttl: 60 }) segment renders a per-execution timestamp, and without the capture data snapshot (the stacked follow-up, feat(router): PPR capture data snapshot — shell/payload parity on a HIT #647) a PPR shell whose baked content drifts from the fresh hydration payload triggers React's client-side tree regeneration — which made the classic blog-cache suite (sidebar-preserve, index render) timing-dependent on whether the ttl-60 refresh landed mid-test. Theme-hydration coverage lives where it is deterministic: the test-app shell-cache theme e2e (dark-cookie fidelity fixture) + the units. PPR over the realistic blog shape (sidebar, ring-3 segment, timestamps) is covered on feat(router): PPR capture data snapshot — shell/payload parity on a HIT #647 via dedicated /ppr-blog fixtures once the snapshot makes it drift-proof.

@ivogt
ivogt force-pushed the fix/ppr-theme-capture branch from 9f2ff16 to a0be901 Compare July 3, 2026 19:17
@ivogt
ivogt force-pushed the fix/ppr-theme-capture branch from a0be901 to 10e3d0c Compare July 3, 2026 20:39
Base automatically changed from feat/ppr-cache-opt-in to main July 3, 2026 21:02
…Theme, re-sync from cookie post-mount

A dark-theme visitor hitting a PPR'd route got the LIGHT captured shell and a
dead theme toggle. initialTheme is per-request metadata (reqCtx.theme) feeding
ThemeProvider in SsrRoot — not part of the cached segments — so a capture made
by a default-light request froze a light prelude while the visitor's resume
tail rendered a divergent tree above the holes; React resume requires them to
match, so stitching/hydration broke: wrong theme AND dead interactivity.

Fix, three coordinated parts:
- ShellCacheEntry.initialTheme records the theme the capture's payload was
  built with (captureAndStoreShell).
- serveShellHit's tail overrides payload.metadata.initialTheme with the stored
  value, so the resume tree AND client hydration match the frozen prelude by
  construction.
- ThemeProvider re-syncs its state post-mount from an EXPLICITLY stored
  cookie/localStorage theme (explicit only — the defaultTheme fallback must
  not override a server-provided initialTheme), so the visitor's real theme
  wins visually: FOUC script pre-paint, provider state after mount.

cloudflare-basic /blog and /blog/:slug deliberately stay NON-ppr on this
branch: their ring-3 cache() (ttl 60) refreshes with a new rendered timestamp,
and without the capture data snapshot (the follow-up branch) a PPR shell whose
baked content drifts from the fresh hydration payload detonates hydration —
which made the classic blog-cache suite timing-dependent. Theme-hydration
coverage lives where it is deterministic: the test-app shell-cache theme e2e
and the units.

Tests: unit — capture stores initialTheme; the HIT tail replays it into the
payload while the visitor's reqCtx.theme differs; ThemeProvider cookie re-sync
(+ no-op without an explicit stored value). E2e (dev + production, test-app):
warm with no cookie, visit with a dark cookie — dark class applies and STAYS,
zero hydration errors, counter interactive. Design doc + /ppr skill updated.
@ivogt
ivogt force-pushed the fix/ppr-theme-capture branch from 10e3d0c to 683c250 Compare July 3, 2026 21:03
@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@646

commit: 683c250

@ivogt
ivogt merged commit 20f0f6c into main Jul 3, 2026
57 of 58 checks passed
@ivogt
ivogt deleted the fix/ppr-theme-capture branch July 3, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant