Repository navigation
fix(router): PPR resume theme fidelity — replay the capture's initialTheme, re-sync from cookie post-mount - #646
Merged
Merged
Conversation
ivogt
force-pushed
the
fix/ppr-theme-capture
branch
from
July 3, 2026 19:17
9f2ff16 to
a0be901
Compare
4 tasks done
ivogt
force-pushed
the
fix/ppr-theme-capture
branch
from
July 3, 2026 20:39
a0be901 to
10e3d0c
Compare
…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
force-pushed
the
fix/ppr-theme-capture
branch
from
July 3, 2026 21:03
10e3d0c to
683c250
Compare
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
initialThemeis per-request metadata (reqCtx.theme, the visitor's cookie) feedingThemeProviderinsideSsrRoot— 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. Reactresumerequires that tree to match the prerendered one, so stitching/hydration broke — wrong theme AND dead interactivity in one failure.Before vs after
Consumer-visible behavior
Nothing to configure. On a HIT whose visitor theme differs from the capture's:
useThemereaders correctThe re-sync only honors an explicitly stored cookie/localStorage value — the
defaultThemefallback never overrides a server-providedinitialThemefor 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—captureAndStoreShellrecords the derived capture context'sreqCtx.themeinto the entry.rsc/rsc-rendering.ts—serveShellHit's tail overridespayload.metadata.initialThemewith 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.Test plan
initialTheme(shell-capture.test.ts); HIT tail replays the captured theme while the visitor'sreqCtx.themediffers (rsc-rendering-shell-ppr.test.ts); ThemeProvider cookie re-sync + no-op-without-explicit-value (theme-provider.test.tsx, localStorage-isolated)./shell-cachewith no cookie (capture bakes light), visit with atheme=darkcookie — 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
initialThemereplayundefined, matching captures made before theme recording — self-consistent; entries roll over via TTL/reactVersion anyway.cloudflare-basic
/blog+/blog/:slugstay NON-ppr on this branch (revised after CI): the blog's ring-3cache({ 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-blogfixtures once the snapshot makes it drift-proof.