Repository navigation
feat(router): PPR capture data snapshot — shell/payload parity on a HIT - #647
Merged
Merged
Conversation
ivogt
force-pushed
the
fix/ppr-theme-capture
branch
from
July 3, 2026 20:39
a0be901 to
10e3d0c
Compare
3 tasks done
ivogt
force-pushed
the
feat/ppr-capture-snapshot
branch
from
July 3, 2026 20:56
53050cc to
896c4f2
Compare
ivogt
force-pushed
the
fix/ppr-theme-capture
branch
from
July 3, 2026 21:03
10e3d0c to
683c250
Compare
A PPR HIT serves the frozen prelude bytes, then runs a FULL FRESH Flight
render for hydration and resumes the holes. React hydrates the frozen prelude
against that fresh payload, so the two must agree on every shell-baked byte.
They don't automatically: any shell content whose cached value DRIFTS between
capture and hit — a cache() segment with a shorter ttl than the shell, a
tag-invalidated "use cache" item — makes the fresh payload disagree with the
prelude, so React throws "server rendered text didn't match the client" and
regenerates the tree client-side (wiping the FOUC theme class, flashing
content). Live repro (theme-independent, fails with no cookie): cloudflare-basic
/blog renders a cache-info timestamp from a cache({ ttl: 60, swr: 300 }) ring-3
segment under a shell with ttl 300; after ~60s the segment revalidates with a
new timestamp and every HIT drifts.
Fix: the capture data snapshot (Next.js resume-data-cache, adapted to Rango's
rings). Core invariant: the snapshot is exactly the set of cache-store reads
the CAPTURE render performed; replaying them on a HIT reproduces the shell
content byte-identically; everything not recorded stays live. This self-aligns
with the hole doctrine — masked loaders never touch the store at capture, so
holes are never pinned and stay fresh.
Mechanics (cache/shell-snapshot.ts + rsc/shell-capture.ts):
- WRITE BARRIER (ordering edge; pins the no-clobber contract the mini
shell-manifest e2e encodes): before the capture's match/render,
settleTrackedBackgroundTasks drains reqCtx._pendingBackgroundTasks — every
waitUntil task is tracked there; the capture's own task opts out via
UNTRACKED_BACKGROUND_TASK. All foreground deferred cache writes are scheduled
before scheduleShellCapture runs, so settling them makes the capture's ring-3
lookup HIT and REPLAY the foreground's generation (handler skipped, module
state untouched, write path gated off by state.cacheHit) instead of racing
the write, re-executing the handler, and overwriting the foreground's entry.
Iterative (a task can schedule a nested write) and deadline-bounded.
- RecordingShellStore wraps the capture's derived-context store and records
read-hits + writes per (family, key), last-write-wins, excluding the shell
family. Item "use cache" writes run inline during the render; ring-3 segment
writes (cacheRoute) are onResponse-gated and never fire for a capture, so
captureAndStoreShell fires the capture's isolated onResponse callbacks with a
synthetic 200 so the segment write runs and is recorded. Both write kinds are
waitUntil-deferred, so the derived context's waitUntil collects the promises
and settleWrites drains them iteratively before draining the snapshot.
- The snapshot rides on the optional ShellCacheEntry.snapshot (JSON-serializable;
responses as base64 body+headers+status). CF/Vercel envelopes cherry-pick
entry fields, so snapshot AND the previously-dropped initialTheme are now
explicitly carried there.
- serveShellHit's tail render reads through a SeededShellStore overlay (on a
derived context, tail only) that serves snapshotted keys AS FRESH
(shouldRevalidate: false — no SWR kick), falls through for everything else,
and passes all writes through.
Doctrine: within a shell's lifetime, shell regions intentionally show
capture-time data — parity beats freshness inside the shell; freshness comes
from holes, the shell's own ttl/swr, and tag invalidation of the SHELL
(ppr.tags). Ring-1/ring-3 tag invalidation does not drop a shell. Residual
consumer responsibility unchanged: uncached nondeterminism (raw Date.now()/
Math.random()/uncached fetch in shell material) still drifts and must live under
a hole — the snapshot pins cache reads, not what was never a cache read.
Fixtures: /blog stays NON-ppr (the classic blog-cache suite is isolated from
capture interference); the PPR'd twin of the same realistic shape — sidebar
parallel, ring-3 cache() ttl 60, per-render timestamp — is /ppr-blog +
/ppr-blog/:slug. Coverage: unit (write barrier ordering + deadline bound,
recording/seeding per family, shell-family exclusion, JSON + putShell/getShell
round-trips incl. CF and Vercel envelopes, capture stores snapshot, HIT tail is
seeded) and dev+prod e2e: drift fixtures (test-app /shell-cache/drift,
cloudflare /ppr-drift), /ppr-blog index + slug HITs hydrating cleanly on the
real KV-backed CFCacheStore, and the mini shell-manifest no-clobber contract.
ivogt
force-pushed
the
feat/ppr-capture-snapshot
branch
from
July 3, 2026 21:24
896c4f2 to
11200b6
Compare
commit: |
This was referenced Jul 3, 2026
Closed
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
A PPR HIT serves the frozen prelude bytes, then runs a full fresh Flight render for hydration and resumes the holes. React hydrates the frozen prelude against that fresh payload, so the two must agree on every shell-baked (non-hole) byte. They don't automatically: any shell content whose cached value drifts between capture time and hit time makes the fresh payload disagree with the prelude, and React throws "server rendered text didn't match the client" and regenerates the tree client-side — wiping the FOUC theme class and flashing content.
Live repro (theme-independent, fails with no cookie): a blog-shaped route renders a cache-info timestamp from a
cache({ ttl: 60, swr: 300 })ring-3 segment under a shell withttl: 300. After ~60s the ring-3 segment revalidates with a new timestamp, so every subsequent HIT's payload timestamp differs from the prelude's baked one → hydration mismatch pointing at<p data-testid="cache-info">. This is why the base branch (#646) keeps/blognon-ppr.The fix: the capture data snapshot
Next.js's resume-data-cache, adapted to Rango's cache rings. Core invariant: the snapshot is exactly the set of cache-store reads the CAPTURE render performed; replaying them on a HIT reproduces the shell content byte-identically; everything not recorded stays live. This self-aligns with the hole doctrine — masked loaders never touch the store at capture, so holes are never pinned and stay fresh.
Before / after (consumer sees no code change — the shape becomes safe)
The consumer writes nothing new. A shell-baked
cache()/"use cache"value is pinned at capture and replayed on every HIT for the shell's lifetime; the live holes belowloading()still update per request.Semantics
cache()segment /"use cache"item read (HIT at capture)cache()/"use cache"MISS at capture (rendered fresh + written)loading()(a hole)Date.now()/Math.random()/uncachedfetch)getShell/putShell)Doctrine: within a shell's lifetime, shell regions intentionally show capture-time data — parity beats freshness inside the shell. Freshness comes from holes, the shell's own ttl/swr, and tag invalidation of the shell (
ppr.tags). Ring-1/ring-3 tag invalidation does not drop a shell.The no-clobber contract (write barrier)
A capture must never clobber a ring-3 entry the foreground produced; when a foreground entry exists (or its write is still in flight when the capture starts), the capture replays and records THAT entry. The mini
shell manifeste2e encodes this contract from the consumer seat (a reload must keep the shell's handler seq stable while loader prices advance).Mechanism — an ordering edge, not a narrower race: every
waitUntiltask promise is tracked onRequestContext._pendingBackgroundTasks, andattemptCapturesettles that list (settleTrackedBackgroundTasks: iterative, becausecacheRouteschedules its actualstore.setin a nestedwaitUntil; deadline-bounded so a hung consumer task degrades instead of stalling) before the capture'srouter.match. All foreground deferred cache writes are scheduled beforescheduleShellCaptureruns (the response and itsonResponsecallbacks commit first), so after the barrier the capture's ring-3 lookup deterministically HITs, replays the foreground's generation (handler skipped,state.cacheHitgates the write path off), and records that generation into the snapshot. A get-before-set check would only have shrunk the race window; the barrier removes it. The capture's own task opts out of tracking (UNTRACKED_BACKGROUND_TASK) so the drain never awaits its own promise.Code-change summary
cache/shell-snapshot.ts(new) —RecordingShellStore(records read-hits + writes per(family, key), last-write-wins, excludes the shell family;settleWritesdrains deferred+nested writes iteratively) andSeededShellStore(serves snapshotted keys as fresh withshouldRevalidate: false, falls through otherwise, passes writes through).rsc/shell-capture.ts— the pre-render write barrier (above); the capture's derived context reads through aRecordingShellStoreand itswaitUntilcollects deferred writes;captureAndStoreShellfires the capture's isolatedonResponsecallbacks with a synthetic 200 so the ring-3 segment write (cacheRoute, otherwiseonResponse-gated and never fired for a capture) runs and is recorded, then drains the snapshot ontoShellCacheEntry.snapshot.server/request-context.ts—_pendingBackgroundTaskstracking inwaitUntil(same deferral + error-logging semantics as before) + theUNTRACKED_BACKGROUND_TASKopt-out marker.rsc/rsc-rendering.ts—serveShellHit's tail render runs insiderunWithRequestContextover a derived context whose_cacheStoreis aSeededShellStore(tail only; sharedreqCtxuntouched). No snapshot → pre-snapshot behavior preserved.cache/types.ts— optionalShellCacheEntry.snapshot+ShellSnapshotRecord/ShellSnapshotItemValue/ShellSnapshotResponseValue.cache/cf/cf-cache-store.ts,cache/vercel/vercel-cache-store.ts— the KV/Blob envelopes cherry-pick entry fields, sosnapshotand the previously-droppedinitialThemeare now explicitly carried (sn/i). Without this the feature (and the base-branch theme replay) silently no-op on the real stores cloudflare-basic uses.Test plan
cache/__tests__/shell-snapshot.test.ts(recording hits+writes per family, shell-family exclusion, last-write-wins,settleWritesbounded/iterative, seeding fresh with no revalidation kick, fallthrough, writes pass through, response + full JSON +putShell/getShellround-trips),shell-capture.test.ts(write barrier settles foreground + nested tasks before the capture match; barrier deadline bound; capture stores the snapshot; empty when no store touched),rsc-rendering-shell-ppr.test.ts(HIT tail is seeded; no-snapshot HIT reads live), CF + Vercel envelope round-trip incl.snapshot/initialTheme./shell-cache/drift(short-ttl shell value survives its ttl with byte parity; zero hydration errors while the price hole advances), cloudflare-basic/ppr-drift(same, real KV-backedCFCacheStore),/ppr-blogindex + slug HITs hydrating cleanly (theme + snapshot end-to-end, realistic sidebar + ring-3 shape), and the minishell manifestno-clobber contract (was the regression; now deterministic across repeated runs). Full suites green: shell-cache 17+17, ppr-shell 14+14, blog-cache 11+11, mini 33 dev + 34 prod.pnpm run typecheck,pnpm run test:unit:all(4803 router units),pnpm run lint,pnpm run format,pnpm check:e2e-bucketing,pnpm check:docs-api.router.named-routes.gen.tsin test-app (shellCacheDrift) and cloudflare-basic (pprDrift,pprBlog,pprBlogPost).Notes / call-outs
/ppr-bloginstead of ppr on/blog: the classic blog-cache suite (sidebar-preserve, index render, document-cache interplay) must stay isolated from capture interference forever — its assertions are about ring-3/document caching, not PPR. The PPR'd twin duplicates the exact realistic shape (same components/loaders, sidebar parallel, ring-3cache()ttl 60 with per-render timestamps) underppr, so the snapshot is exercised against real drift without coupling two suites to one fixture.updateTag/revalidateTagdoes not invalidate a shell — couple them by putting the same tag inppr.tags.loading()boundary is seeded everywhere, so that one key shows capture-time data inside the hole. Consistent by design.initialTheme, so the base-branch theme replay was ineffective on the real stores; carried alongsidesnapshothere (cache-store serialization, nottheme/).onResponse-firing during capture is load-bearing for ring-3 segment parity — the derived context's_onResponseCallbacksis isolated and holds only capture match-middleware callbacks (HTTP middleware never runs for a capture), so firing a synthetic 200 is safe; post-barrier it writes either the replayed foreground generation or a genuinely-missing key, never a clobber.fix/ppr-theme-capture); base set accordingly. Docs updated in the same commit: design doc scar-tissue section (incl. the write barrier as mechanic 0 and the no-clobber contract),/pprskill pitfalls, feature-map + feature-file-map.