Skip to content

feat(router): PPR shell caching — createShellCacheMiddleware, prelude+postponed store family, fizz resume - #641

Merged
ivogt merged 1 commit into
mainfrom
feat/ppr-shell-cache
Jul 3, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/ppr-shell-cache

Conversation

@ivogt

@ivogt ivogt commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Even on a full segment/prerender cache hit, the worker re-does the whole HTML tier on every document request: deserialize stored segments, re-encode a fresh Flight stream, then run a complete fizz render over the entire tree. TTFB always pays for the shell render and every upstream read it needs. The only HTML-level cache a consumer had was the all-or-nothing document cache — which freezes loader output too, so any page with live data (prices, stock, per-request content) could not be HTML-cached at all.

React 19.2 shipped Partial Pre-rendering in stable (prerender from react-dom/static + resume from react-dom/server). This PR uses it to add a second, opt-in render axis: cache the rendered HTML shell (prelude bytes + postponed state), serve those bytes on the first read of the request, and resume fizz for just the live loader holes. The browser sees one ordinary streamed document.

Design doc: packages/rangojs-router/docs/design/ppr-shell-resume.md (canonical; includes the POC evidence and the hole contract).

Before / after

// BEFORE — live-data pages could not be HTML-cached: the document cache
// freezes loader output, so /products served stale prices for the whole TTL.
const router = createRouter<AppEnv>()
  .use("/products", createDocumentCacheMiddleware({ ttlSeconds: 300 }))
  .route(...);

// AFTER — the shell (layouts, nav, static content, hydration islands) is
// cached and flushed instantly; loaders stay live and stream into the shell
// per request. First byte no longer waits on the shell render or the loader.
const router = createRouter<AppEnv>()
  .use("/products", createShellCacheMiddleware({ ttlSeconds: 300, swrSeconds: 60 }))
  .route(...);

End-to-end consumer example

The hole contract: shell material lives in a layout; the loader-consuming route below it carries loading() — that boundary is where capture postpones and resume stitches. (Reference fixtures: tests/cloudflare-basic/src/pages/ppr-shell.tsx, packages/rangojs-router/e2e/test-app/src/urls/shell-cache.tsx.)

// router.tsx
const router = createRouter<AppEnv>({ cache: (env) => ({ store: new CFCacheStore({ kv: env.CACHE_KV }) }) })
  .use("/product", createShellCacheMiddleware({ ttlSeconds: 600, swrSeconds: 120 }))
  .routes(urls);

// urls.tsx — shell in the layout, hole behind loading()
layout(ProductShellLayout, () => [            // header, nav, islands, handle reads = shell
  path("/product/:id", PricePage, { name: "product" }, () => [
    loader(LivePriceLoader),                  // masked at capture, fresh on every serve
    loading(<PriceSkeleton />),               // the hole boundary capture postpones at
  ]),
]),

// Second hole primitive — a deterministic hole for ANY boundary, even when
// the data is already resolved (Promise.resolve, warm in-memory reads):
async function Greeting() {
  const name = await live(() => Promise.resolve(currentUserName())); // hole, not shell
  return <span>Hi {name}</span>;
}

First GET: x-rango-shell: MISS, normal axis-1 stream, background capture stores { prelude, postponed }. Later GETs: x-rango-shell: HIT — the frozen prelude (layout + skeleton) flushes before the loader even starts; the live price + React's $RC stitch arrive in the same response.

Semantics

Case Behavior
GET HTML document, no entry (MISS) axis-1 stream to user + x-rango-shell: MISS; background capture via router.match() under a derived context (middleware chain does NOT re-run)
GET HTML document, fresh entry (HIT) prelude bytes flushed first, fizz resume fills only the holes, x-rango-shell: HIT; status/headers/Set-Cookie come from the live pass
Stale entry within SWR window served as HIT + background recapture
Loader route without loading() no capturable shell (loader data is awaited at tree-build); capture refuses, once-per-key console.warn, route stays on axis 1 forever
live(() => ...) boundary (thunk or promise form) deterministic hole — masked at capture exactly like a loader, even for already-resolved data; thunk never runs during capture
Unwrapped promise, genuinely pending real I/O hole by physics — cannot win the task-quantized capture window
Unwrapped promise, resolved/microtask-resolvable reliably SHELL (settles in the same window as sync JSX) — a deterministic bug if per-request; use live()
live() inside "use cache" or cache() scope throws — the inner cache rings freeze before capture could mask (same guard family as cookies())
non-GET / _rsc_action / _rsc_loader / _rsc_partial / RSC Accept bypass — axis 1 untouched
CSP nonce in play bypass (frozen prelude cannot carry a fresh nonce)
streamMode: "allReady" bypass (buffering defeats PPR)
redirect / 404 / error render no resume (marker absent), no capture; response passes through untouched
reactVersion mismatch on stored entry treated as MISS; recapture overwrites (postponed blobs are build-coupled)
store lacks getShell/putShell middleware fails open to axis 1
cookies() / headers() during the capture render throw (same guard family as "use cache" / cache()) — a shared shell must not bake one user's identity; read them in loaders (the holes) instead
Actions / PE / formState always axis 1

What changed (key mechanics)

  • Middleware createShellCacheMiddleware (src/cache/shell-cache.ts, exported from @rangojs/router/cache): bypass matrix, getShell + reactVersion gate, marker-gated composition (prepends prelude bytes iff the render layer set the internal x-rango-shell-resumed marker), host-scoped default keys (${host}${pathname}${sortedSearch}:shell — multi-tenant safe). Calls next() exactly once on every path; on MISS/stale it sets a _shellCapture descriptor before that single next() (the executor's per-entry next() is a single-use latch — an earlier design that re-ran next() for capture threw "Middleware called next() more than once" in real workers).
  • Render orchestration (src/rsc/rsc-rendering.ts, src/rsc/shell-capture.ts, src/rsc/full-payload.ts): resume branch (final authority: nonce/allReady/partial/feature-detect guards) marks the response; after the response is built, scheduleShellCapture (stampede-guarded) runs the capture in the background — a derived request context (Object.create(reqCtx): fresh handle store, fresh tag set, _shellCaptureRun: true) re-derives the shell via router.match(), renders a fresh Flight stream over the shared buildFullPayload, then seal → quiesce → prerender + abort → putShell. Quiesce is task-quantized, not wall-clock: the capture Flight stream runs through a gate that declares quiet after 2 consecutive byte-silent macrotask hops and then FREEZES (never closes/errors — pending rows stay pending so fizz postpones them; a late error row can never corrupt a prelude); the fizz side aborts after fixed task hops (maxWaitMs remains only as a pathological guard). Masked loaders emit no bytes, so shell settlement is measured against genuine byte-silence — genuinely pending promises reliably become holes, microtask-resolvable values reliably land in the shell (the same discriminator Next 16 uses, verified against its source).
  • Loader masking (src/router/segment-resolution/loader-mask.ts, gated in loader-cache.ts + fresh.ts): during the active capture run only (_shellCaptureRun), route loaders are never executed — their slots get never-resolving promises so the loading() boundary postpones. Strict no-op when the flag is unset.
  • SSR strategies (src/ssr/index.tsx, src/ssr/ssr-root.tsx): createShellCaptureHandler / createShellResumeHandler over the extracted createSsrRootComponent, wired by the SSR virtual entry (virtual-entries.ts) importing prerender from react-dom/static.edge and resume from react-dom/server.edge. Capture sanity gate refuses trivial preludes (no <body>); resume handles the no-holes DATA variant.
  • Store family (src/cache/types.ts ShellCacheEntry + optional getShell/putShell): implemented in the memory, Cloudflare KV, and Vercel runtime-cache stores; participates in tag invalidation; the middleware threads its resolved store onto the capture descriptor so a store-attached middleware reads and writes ONE store.
  • Capture guard (src/server/cookie-store.ts): cookies()/headers() throw during the active capture render, turning a silent cross-user shell leak into a loud capture-time error.
  • live() (src/server/live.ts, exported from @rangojs/router): the deterministic hole primitive — our analogue of Next's connection()/hanging-promise pattern, but stronger: the thunk form live(() => fetchX()) never runs during capture (no cost, no side effects), where connection() only marks dynamic. During the capture run it returns a never-settling promise (identical lifecycle to the loader mask); on serve and client it is a passthrough. Guarded against "use cache"/cache() scopes: the inner cache rings deep-settle and freeze values before ring-4 capture could ever mask them, so live() there throws with a use-a-loader message instead of silently freezing (or leaking per-user data into) a shared cache entry.
  • Consumer skill (skills/ppr/SKILL.md, indexed in /rango and /cache-guide): the creating-holes decision table (intent → mechanism), the hole contract, the cache onion (PPR as ring 4 between segment cache and document cache), loader-carried promises (three-layer HIT streaming), the passed-promises latency-class doctrine, live(), partial navigations (soft navs are already PPR-shaped at the data tier — bypass is by design), execution matrix, and a first-class security section — including a Next.js-PPR false-friends row (opt-in runtime middleware, loading() holes; a hand-rolled <Suspense> is not a hole).
  • Parked follow-up spec (docs/design/vercel-chain-ppr.md): the Vercel CDN-stitched chain protocol (PoP-served shell + POST next-resume with the postponed state length-prefixed inside the fallback file), reverse-engineered from @vercel/next source, with the HMAC-envelope security requirement for any body-transported postponed state. Documented, deliberately not implemented.

Why the background capture uses router.match() instead of a pipeline re-run: the middleware chain (auth, logging, the single-use next() latch) must run once per request; match() re-runs only route handlers/segment resolution and inherits the foreground's post-middleware context state.

Test plan

  • Unit: 4780 passed (296 files) + 66 RSC — includes new suites for the middleware (bypass matrix, marker composition, SWR, host scoping, single-next regression under a latched next()), capture orchestration (runShellCapture happy/redirect/null/stampede, store-attached two-store round-trip through the real write path), loader masking (flag semantics: descriptor never masks, only the active run), cookie/header capture guards, SSR capture/resume handlers (fake-deps seam), and CF/Vercel/memory shell store families
  • Userland dogfood (mini, @rangojs/router/testing runMiddleware + real memory store): MISS tagging, bypass, HIT composition (prelude-first, $RC + __FLIGHT_DATA in the same body, marker stripped) — 23 passed
  • E2E dev and production, cloudflare-basic (e2e/ppr-shell.test.ts) and test-app (e2e/shell-cache.test.ts): 10/10 in each of the four runs — MISS→HIT header flip; HIT first chunk = frozen shell + fallback, no live content, TTFB under the 400ms loader delay; live content + $RC stitch in the same response; loader-carried promise three-layer ordering (shell → outer + inner fallback → inner value); live(() => Promise.resolve(...)) rendered as a hole (fallback before </html>, value after); no-loading() route stays MISS forever while its nested promise still streams on axis 1; HIT hydration with zero hydration errors; RSC bypass; live-path render + soft-nav
  • Semantic matrix: 39/39 across projects (axis-1 execution guarantees untouched)
  • pnpm run typecheck (15/15), pnpm run lint, pnpm run format, pnpm check:e2e-bucketing — all green on the rebased branch

Notes

  • The hole contract is the headline consumer rule: a hole exists only where the route-level loading() boundary separates loader consumption from the shell. A hand-rolled <Suspense> + useLoader is NOT a hole (loader data is awaited at tree-build in the loading-less branch and useLoader reads resolved context data). That shape correctly refuses capture and now warns once per key — previously it was a silent eternal MISS, which is what made this expensive to diagnose. Documented in the design doc ("The hole contract") with the repro evidence.
  • Consistency: the fresh Flight payload is hydration's source of truth; shell content that is not cache-replayed or deterministic degrades to a hydration repair, bounded by TTL/SWR. Guidance: cache() the route so the same replayed segments feed capture and every resume; keep per-request data in loaders.
  • Document cache vs shell cache: pick one per route — stacking both makes the document cache cache the composite, which is correct output but makes shell caching redundant there.
  • Promise doctrine (deterministic capture): a promise's latency class picks its side — genuinely pending promises reliably become holes (they cannot win a task-quantized window); resolved/microtask promises reliably land in the shell. Where intent must decide instead of latency, say it in code: live() for a guaranteed hole, a loader behind loading() for the route-level live lane, await for shell material. The only nondeterministic sliver left is I/O completing within ~2 event-loop turns of shell quiet — self-healing via TTL/recapture, and only reachable by code that declared no intent.
  • A cold-worker capture can occasionally abort mid-render (AbortError, logged as retryable via reportCacheError); the next request recaptures and hits — self-healing by design.
  • Out of scope (v1, tracked in the design docs): build-time capture in the prerender pipeline, the warm-then-render two-pass for network-fed uncached shells (research attached in the design doc), the Vercel chain adapter (spec parked in docs/design/vercel-chain-ppr.md), CF Cache-API L1 tier for shell entries, deleteShell (mismatched entries age out via TTL), Flight-byte splicing for partial navigations.

@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@641

commit: 1090a64

…istic capture, live() holes, fizz resume

Axis 2 of the two-axis render model (docs/design/ppr-shell-resume.md):
createShellCacheMiddleware captures the rendered HTML shell (react-dom
static prerender prelude + postponed state, React 19.2 stable PPR surface)
into the cache store and, on a hit, streams the shell bytes before any
render work, then resumes fizz for just the live holes. Axis 1 (full fizz
per request) is untouched and remains the default; every ineligible
request fails open to it.

Core:
- SSR strategies: createShellCaptureHandler / createShellResumeHandler
  (@rangojs/router/ssr), shared SsrRoot builder, DATA-variant handling
- Store family: getShell/putShell on SegmentCacheStore; memory, CF (KV)
  and Vercel implementations with tag invalidation; atomic
  prelude+postponed entries keyed by React version
- Middleware calls next() exactly once; capture runs as a render-layer
  background task via router.match() under a derived context
  (_shellCapture descriptor / _shellCaptureRun marker) — the middleware
  chain never re-runs for capture
- Hole contract: holes exist where a route-level loading() boundary is;
  loaders are masked at capture (never execute) and always run fresh on
  serve; blocking loader routes refuse capture with a once-per-key warning

Deterministic capture (replaces wall-clock quiesce):
- Flight capture gate: quiesce = 2 consecutive byte-quiet macrotask hops,
  then freeze (never close/error — pending rows postpone; late error rows
  cannot corrupt a prelude); fizz side aborts after fixed task hops.
  Genuinely pending promises reliably become holes; microtask-resolvable
  values reliably land in the shell
- live() (thunk + promise forms): deterministic hole for any boundary,
  including already-resolved data — masked at capture exactly like a
  loader; thunk form skips the work entirely. Guarded against use-cache
  and cache() scopes (the inner rings freeze before capture could mask)

Security:
- Default keys are host-scoped (multi-tenant shell isolation)
- cookies()/headers() throw during capture so per-user data cannot bake
  into the shared shell

Coverage: unit suites for every layer (incl. userland live() and mask
tests); e2e dev+production in cloudflare-basic and test-app (MISS→HIT,
shell-before-hole streaming, loader-carried promise three-layer ordering,
live() resolved-promise hole, no-loading() negative, zero hydration
errors); mini dogfood suite; semantic matrix green.

Docs: design doc (contract + scar tissue), /ppr skill (creating-holes
decision table, cache onion, partial navigations, execution matrix,
security), feature-map/file-map sync, parked Vercel chain adapter spec
(docs/design/vercel-chain-ppr.md).
@ivogt
ivogt force-pushed the feat/ppr-shell-cache branch from bc61321 to 1090a64 Compare July 3, 2026 07:47
@ivogt
ivogt merged commit e0537b4 into main Jul 3, 2026
30 checks passed
@ivogt
ivogt deleted the feat/ppr-shell-cache branch July 3, 2026 10:11
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