Repository navigation
feat(router): PPR shell caching — createShellCacheMiddleware, prelude+postponed store family, fizz resume - #641
Merged
Merged
Conversation
commit: |
ivogt
force-pushed
the
feat/ppr-shell-cache
branch
from
July 3, 2026 05:04
a785d84 to
bc61321
Compare
…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
force-pushed
the
feat/ppr-shell-cache
branch
from
July 3, 2026 07:47
bc61321 to
1090a64
Compare
This was referenced Jul 3, 2026
Merged
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
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 (
prerenderfromreact-dom/static+resumefromreact-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
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.)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$RCstitch arrive in the same response.Semantics
x-rango-shell: MISS; background capture viarouter.match()under a derived context (middleware chain does NOT re-run)resumefills only the holes,x-rango-shell: HIT; status/headers/Set-Cookie come from the live passloading()console.warn, route stays on axis 1 foreverlive(() => ...)boundary (thunk or promise form)live()live()inside"use cache"orcache()scopecookies())_rsc_action/_rsc_loader/_rsc_partial/ RSC AcceptstreamMode: "allReady"reactVersionmismatch on stored entrygetShell/putShellcookies()/headers()during the capture render"use cache"/cache()) — a shared shell must not bake one user's identity; read them in loaders (the holes) insteadWhat changed (key mechanics)
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 internalx-rango-shell-resumedmarker), host-scoped default keys (${host}${pathname}${sortedSearch}:shell— multi-tenant safe). Callsnext()exactly once on every path; on MISS/stale it sets a_shellCapturedescriptor before that singlenext()(the executor's per-entrynext()is a single-use latch — an earlier design that re-rannext()for capture threw"Middleware called next() more than once"in real workers).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 viarouter.match(), renders a fresh Flight stream over the sharedbuildFullPayload, 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 (maxWaitMsremains 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).src/router/segment-resolution/loader-mask.ts, gated inloader-cache.ts+fresh.ts): during the active capture run only (_shellCaptureRun), route loaders are never executed — their slots get never-resolving promises so theloading()boundary postpones. Strict no-op when the flag is unset.src/ssr/index.tsx,src/ssr/ssr-root.tsx):createShellCaptureHandler/createShellResumeHandlerover the extractedcreateSsrRootComponent, wired by the SSR virtual entry (virtual-entries.ts) importingprerenderfromreact-dom/static.edgeandresumefromreact-dom/server.edge. Capture sanity gate refuses trivial preludes (no<body>); resume handles the no-holes DATA variant.src/cache/types.tsShellCacheEntry+ optionalgetShell/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.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'sconnection()/hanging-promise pattern, but stronger: the thunk formlive(() => fetchX())never runs during capture (no cost, no side effects), whereconnection()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, solive()there throws with a use-a-loader message instead of silently freezing (or leaking per-user data into) a shared cache entry.skills/ppr/SKILL.md, indexed in/rangoand/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).docs/design/vercel-chain-ppr.md): the Vercel CDN-stitched chain protocol (PoP-served shell +POST next-resumewith the postponed state length-prefixed inside the fallback file), reverse-engineered from@vercel/nextsource, 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-usenext()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
next()), capture orchestration (runShellCapturehappy/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@rangojs/router/testingrunMiddleware+ real memory store): MISS tagging, bypass, HIT composition (prelude-first,$RC+__FLIGHT_DATAin the same body, marker stripped) — 23 passede2e/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 +$RCstitch 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-navpnpm run typecheck(15/15),pnpm run lint,pnpm run format,pnpm check:e2e-bucketing— all green on the rebased branchNotes
loading()boundary separates loader consumption from the shell. A hand-rolled<Suspense>+useLoaderis NOT a hole (loader data is awaited at tree-build in the loading-less branch anduseLoaderreads 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.cache()the route so the same replayed segments feed capture and every resume; keep per-request data in loaders.live()for a guaranteed hole, a loader behindloading()for the route-level live lane,awaitfor 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.AbortError, logged as retryable viareportCacheError); the next request recaptures and hits — self-healing by design.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.