Skip to content

feat(router): PPR capture data snapshot — shell/payload parity on a HIT - #647

Merged
ivogt merged 1 commit into
mainfrom
feat/ppr-capture-snapshot
Jul 3, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/ppr-capture-snapshot

Conversation

@ivogt

@ivogt ivogt commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 with ttl: 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 /blog non-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)

// A shell whose baked value comes from a short-lived cache() ring
export const urlpatterns = urls(({ path, layout, loader, loading, cache }) => [
  layout(BlogLayout, () => [
    cache({ ttl: 60, swr: 300 }, () => [
      // Before: this drifted after ~60s → hydration mismatch, theme flash.
      // After:  the snapshot pins the cached segment; every HIT stays byte-identical.
      path("/ppr-blog", BlogIndexPage, { name: "pprBlog", ppr: { ttl: 300, swr: 120 } }),
    ]),
  ]),
]);

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 below loading() still update per request.

Semantics

Shell content at capture On a HIT (within shell ttl) After the underlying entry drifts (expired / recomputed / tag-invalidated)
cache() segment / "use cache" item read (HIT at capture) recorded → seeded → parity recorded → seeded from snapshot → parity (no drift)
cache() / "use cache" MISS at capture (rendered fresh + written) write recorded → seeded → parity write recorded → seeded → parity
Foreground-produced ring-3 entry, write still in flight at capture time write barrier settles it → capture replays the FOREGROUND's generation (never clobbers it) same — prelude, snapshot, and ring-3 agree on the foreground's generation
Loader behind loading() (a hole) not recorded → runs live not recorded → runs live
Uncached nondeterminism in shell (Date.now()/Math.random()/uncached fetch) not a cache read → cannot be pinned; hydration hazard — must live under a hole same
Shell-family (getShell/putShell) never recorded (snapshot rides inside the entry) n/a

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 manifest e2e 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 waitUntil task promise is tracked on RequestContext._pendingBackgroundTasks, and attemptCapture settles that list (settleTrackedBackgroundTasks: iterative, because cacheRoute schedules its actual store.set in a nested waitUntil; deadline-bounded so a hung consumer task degrades instead of stalling) before the capture's router.match. All foreground deferred cache writes are scheduled before scheduleShellCapture runs (the response and its onResponse callbacks commit first), so after the barrier the capture's ring-3 lookup deterministically HITs, replays the foreground's generation (handler skipped, state.cacheHit gates 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; settleWrites drains deferred+nested writes iteratively) and SeededShellStore (serves snapshotted keys as fresh with shouldRevalidate: false, falls through otherwise, passes writes through).
  • rsc/shell-capture.ts — the pre-render write barrier (above); the capture's derived context reads through a RecordingShellStore and its waitUntil collects deferred writes; captureAndStoreShell fires the capture's isolated onResponse callbacks with a synthetic 200 so the ring-3 segment write (cacheRoute, otherwise onResponse-gated and never fired for a capture) runs and is recorded, then drains the snapshot onto ShellCacheEntry.snapshot.
  • server/request-context.ts — _pendingBackgroundTasks tracking in waitUntil (same deferral + error-logging semantics as before) + the UNTRACKED_BACKGROUND_TASK opt-out marker.
  • rsc/rsc-rendering.ts — serveShellHit's tail render runs inside runWithRequestContext over a derived context whose _cacheStore is a SeededShellStore (tail only; shared reqCtx untouched). No snapshot → pre-snapshot behavior preserved.
  • cache/types.ts — optional ShellCacheEntry.snapshot + ShellSnapshotRecord/ShellSnapshotItemValue/ShellSnapshotResponseValue.
  • cache/cf/cf-cache-store.ts, cache/vercel/vercel-cache-store.ts — the KV/Blob envelopes cherry-pick entry fields, so snapshot and the previously-dropped initialTheme are 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

  • Unit — cache/__tests__/shell-snapshot.test.ts (recording hits+writes per family, shell-family exclusion, last-write-wins, settleWrites bounded/iterative, seeding fresh with no revalidation kick, fallthrough, writes pass through, response + full JSON + putShell/getShell round-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.
  • e2e dev + production — test-app /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-backed CFCacheStore), /ppr-blog index + slug HITs hydrating cleanly (theme + snapshot end-to-end, realistic sidebar + ring-3 shape), and the mini shell manifest no-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.
  • Regenerated router.named-routes.gen.ts in test-app (shellCacheDrift) and cloudflare-basic (pprDrift, pprBlog, pprBlogPost).

Notes / call-outs

  • Why /ppr-blog instead 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-3 cache() ttl 60 with per-render timestamps) under ppr, so the snapshot is exercised against real drift without coupling two suites to one fixture.
  • Tag-invalidation doctrine: ring-1/ring-3 updateTag/revalidateTag does not invalidate a shell — couple them by putting the same tag in ppr.tags.
  • Seeded-hole edge: a key read both above and below a loading() boundary is seeded everywhere, so that one key shows capture-time data inside the hole. Consistent by design.
  • Discovered base-branch gap: the CF/Vercel shell envelopes never carried initialTheme, so the base-branch theme replay was ineffective on the real stores; carried alongside snapshot here (cache-store serialization, not theme/).
  • onResponse-firing during capture is load-bearing for ring-3 segment parity — the derived context's _onResponseCallbacks is 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.
  • Stacked on fix(router): PPR resume theme fidelity — replay the capture's initialTheme, re-sync from cookie post-mount #646 (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), /ppr skill pitfalls, feature-map + feature-file-map.

@ivogt
ivogt force-pushed the fix/ppr-theme-capture branch from a0be901 to 10e3d0c Compare July 3, 2026 20:39
@ivogt
ivogt force-pushed the feat/ppr-capture-snapshot branch from 53050cc to 896c4f2 Compare July 3, 2026 20:56
@ivogt
ivogt force-pushed the fix/ppr-theme-capture branch from 10e3d0c to 683c250 Compare July 3, 2026 21:03
Base automatically changed from fix/ppr-theme-capture to main July 3, 2026 21:23
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
ivogt force-pushed the feat/ppr-capture-snapshot branch from 896c4f2 to 11200b6 Compare July 3, 2026 21:24
@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@647

commit: 11200b6

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