Skip to content

feat(ppr): producer B — emit the shell entry at build time for prerendered routes (#699) - #701

Merged
ivogt merged 8 commits into
mainfrom
feat/ppr-producer-b
Jul 6, 2026
Merged

ivogt merged 8 commits into
mainfrom
feat/ppr-producer-b

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #699. Builds on the Prerender+ppr composition groundwork merged in #697.

Problem statement

A route combining a build-time handler with the shell option — path("/docs/:slug", Prerender(getParams, handler), { name: "docs", ppr: { ttl, swr } }) — served its first request after every deploy as x-rango-shell: MISS: the complete Flight payload existed since vite build, but the HTML prelude half of the shell entry was produced only by a runtime background capture triggered by traffic. Every deploy paid a cold MISS, capture latency, and warm-up traffic per URL, and the capture path is physics-sensitive (quiet windows, cold module graphs) — the entire CI hardening campaign in #697 exists because of it. This PR produces the complete ShellCacheEntry at vite build (producer B), so the first-ever request serves x-rango-shell: HIT with the frozen prelude and live streaming loader holes. Dev gets the same semantics via on-demand production.

Before vs. after

// The route definition is UNCHANGED — no new API:
path("/pp/:slug", PrerenderPprArticle, { name: "pp.article", ppr: { ttl: 300, swr: 120 } })
# BEFORE (runtime capture only)              # AFTER (producer B)
$ curl -H "Accept: text/html" /pp/alpha      $ curl -H "Accept: text/html" /pp/alpha
x-rango-shell: MISS   <- first request       x-rango-shell: HIT    <- first request
...background capture runs...                (frozen prelude + streaming loader hole,
$ curl -H "Accept: text/html" /pp/alpha       loaders live: seq advances per request)
x-rango-shell: HIT    <- second request

End-to-end consumer example

// src/urls/docs.tsx
export const DocsArticle = Prerender(
  async () => (await listSlugs()).map((slug) => ({ slug })),
  async (ctx) => (
    <article>
      <h1>{(await getDoc(ctx.params.slug)).title}</h1>
      <ParallelOutlet name="@viewers" />
    </article>
  ),
);

export const docsPatterns = urls(({ path, loader, loading, parallel }) => [
  path("/docs/:slug", DocsArticle,
    { name: "docs.article", ppr: { ttl: 86_400, tags: ["docs"] } },
    () => [parallel({
      "@viewers": {
        handler: ViewerBadge,
        use: () => [loader(ViewerCountLoader), loading(<span>...</span>)],
      },
    })],
  ),
]);

vite build now logs SHELL OK /docs/<slug> per URL and the deployed worker serves every first request as a shell HIT: the article HTML is the frozen prelude, the viewer badge streams fresh per request, and updateTag("docs") evicts the baked entries.

Semantics

Case Behavior
First request after deploy, bare pathname x-rango-shell: HIT from the baked manifest entry, served through the same serveShellHit as a captured entry
Request with a query string Unchanged: build entries cover the bare pathname only; search-bearing URLs keep runtime capture (their shell key differs)
Runtime store has an entry Unchanged: the runtime store is read FIRST — a captured entry supersedes the baked one
ppr.ttl elapses Baked entry still serves (no expiry until next deploy) but a runtime recapture is scheduled: SWR is the UPGRADE path from baked to fresher runtime entry, never the bootstrap path
updateTag() on the entry's tag Evicted with zero tombstones: the immutable manifest entry is rejected by tag-marker comparison (isTagsInvalidatedSince(tags, entry.createdAt), new optional store method)
Deploy buildVersion gate + a fresh manifest retire all baked entries
Capture refused at build (identity guard, rejected bake-lane loader, no shell, prerender store not replayed) Loud SHELL SKIP build line; the route keeps runtime-capture semantics — never a wrong-lane bake
Custom store without isTagsInvalidatedSince TAGGED baked entries are not served (warned once — updateTag could never evict them); untagged entries serve
Dev mode Same first-request HIT via the /__rsc_shell on-demand endpoint (below); non-prerendered ppr routes keep runtime capture unchanged
Actions, PE, nonce'd requests, allReady Unchanged — never reach the shell path

Code changes

Build (producer B):

  • vite/discovery/prerender-collection.ts + router/prerender-match.ts: matchForPrerender surfaces the matched route's ppr option; collection records shell candidates and retains Flight payload JSONs on DiscoveryState.
  • vite/discovery/shell-prerender-phase.ts (new): a buildApp post hook — the prelude embeds the BUILT client bootstrap URL, which does not exist at buildStart — reuses the buildStart temp server (kept alive on state), seeds an in-realm prerender store from the retained payloads, and drives the capture per URL. Stages __ps-*.js assets + a lazy __shell-manifest.js and injects globalThis.__loadShellManifestModule into the built RSC entry (mirroring the prerender manifest; the worker handles every request — no static file serving). Manifest keys are pathname-only: router $$ids hash filePath:lineNumber of the TRANSFORMED source and differ between the temp server's transform chain and the main build's, so a temp-realm router id can never be looked up by the shipped worker; cross-router pathname collisions are declined at build instead.
  • prerender/build-shell-capture.ts (new): the capture core over a synthetic build request context (createRequestContext over the build env — no ambient identity, so the identity guard is trivially satisfied). Reuses producer A verbatim via deriveShellCaptureContext (extracted from attemptCapture in rsc/shell-capture.ts — mask funnel, liveness bookkeeping, snapshot recording, implicit doc-cache scope; one implementation, two producers) + the existing captureAndStoreShell. The capture's match() re-enters withCacheLookup and REPLAYS the prerender store — no handler execution, the fix(vite): prerender collection ran trieless — catch-all shadowed nested routes; Prerender + ppr composition coverage #697 composition path — and a fetchedKeys assertion refuses any capture that never consulted the store. Entries are stamped with the MAIN build's version (new api.getBuildVersion() on the version plugin — the temp server's own stamp would fail the serve gate forever).
  • SSR half: composed from the temp server's SSR environment runner (react-dom/static prerender + Flight client) with the built entry as bootstrap. Production-hashed client references (only resolvable in built bundles) bridge to dev refKeys through a wrapped __vite_rsc_client_require__ using the same computeProductionHash reverse map. The fix(vite): prerender collection ran trieless — catch-all shadowed nested routes; Prerender + ppr composition coverage #697 payload-settlement abort gate is inherited — first attempt lands on cold graphs.

Serve:

  • rsc/shell-build-manifest.ts (new): lookupBuildShell on a runtime shell-store MISS in rsc-rendering.ts, serving through the same serveShellHit. Gates: search-less request, reactVersion/buildVersion, payload integrity, tag markers. Every fault degrades to a MISS.
  • cache/types.ts + three stores: new optional isTagsInvalidatedSince(tags, sinceMs) — CF wraps its existing KV tag-marker check; the memory store records per-tag invalidation instants; the Vercel store writes its own tm-family markers on invalidateTags (the platform's expireTag deletes entries and keeps no queryable history; marker writes are strict so a failed one rejects like a failed expireTag).

Dev:

  • /__rsc_shell endpoint (router-discovery.ts), mirroring /__rsc_prerender: runs the same capture core in the dev realms (main-server envs on node; the shared temp Node server on cloudflare, whose virtual:entry-ssr now lazily resolves to the REAL rango SSR entry), memoized per router HMR generation AND caller version. Policy-free: the serve gate sends the resolved ttl/swr/tags/version. A /__rsc_prerender pre-flight warms the payload memo and refuses non-prerenderable routes. The read-through's dev branch arms only for trie pr-flagged routes and its fetch is deadline-bounded at 20s (the response IS an inline capture: attempt + in-place retry).

Bundled fix — handlerLiveHoles dropped by persistent stores (pre-existing #695 bug): both KVShellEnvelope and VercelShellEnvelope omitted the flag, so a handler-live entry read back from KV/Vercel silently armed the handler-free fast path — holes only a handler re-run can fill never filled on a HIT. Fixed with round-trip regression tests, shown red without the envelope fix (2 failed) and green with it (52/52). Producer B needs the flag to survive persistence, which is where it became load-bearing.

Test plan

  • Unit (red-before-green for the envelope fix): shell envelope round-trips (CF + Vercel), isTagsInvalidatedSince on all three stores, lookupBuildShell serve gates incl. the dev branch — 88 tests across 4 files; pnpm run test:unit:all green.
  • E2e, dev + production, both apps: prerender-ppr.test.ts (test-app) and ppr-shell.test.ts (cloudflare-basic) gain: first-request HIT with frozen prelude + hole fallback (pins the zero-runtime-capture contract on bare pathnames), loader liveness across baked HITs (seq advances — loaders never bake), updateTag eviction (dedicated route+tag per app; baked entry rejected via markers, then producer A recaptures). Existing ?probe= composition tests keep passing untouched (search-bearing URLs never read the manifest). Local: test-app 7/7 dev + 7/7 production; cloudflare-basic 24/24 production + 24/24 dev.
  • pnpm run typecheck, pnpm run lint, pnpm run format, pnpm check:e2e-bucketing — green; *.gen.ts regenerated in both apps.
  • Manual: vite preview first-ever request to /pp/alpha = HIT with built bootstrap URL in the prelude; dev server first request = HIT via /__rsc_shell.

Notes

  • Userland testing primitives: no new primitive-reachable API (the ppr path option is unchanged; producer B is build/serve infrastructure), so coverage is unit + dev/production e2e rather than a testing-primitive extension.
  • Router id instability across realms (found here): $$id hashes filePath:lineNumber of TRANSFORMED source, so dev-transform and build-transform ids differ for the same createRouter(). Producer B routes around it with pathname-only manifest keys; worth its own issue — anything else assuming cross-realm id stability (state cookie names?) inherits the same hazard.
  • Store-less apps: a ppr route without a shell-family store still degrades to axis 1 with the existing warning; baked entries are not served without a store. Deliberate: staleness/eviction semantics need the store.
  • Preinit head links: build-time preludes carry the built bootstrap (bootstrapModules form) but not the perf(ssr): head-executing module scripts — preinit client-reference chunks, bootstrapModules entry #694 preinit chunk links (the hook isn't installed in the phase's composed SSR half). Perf-only; a follow-up can feed the built chunk map.
  • Vercel preset ordering: the shell phase's buildApp post hook runs before vercel-output.ts's (plugin order), so .vercel/output picks up the shell manifest; not yet exercised by a vercel e2e.

ivogt added 7 commits July 6, 2026 12:58
…nvelopes

Both persistent stores' shell envelopes (KVShellEnvelope, VercelShellEnvelope)
dropped ShellCacheEntry.handlerLiveHoles: putShell never wrote it and getShell
reconstructed entries without it. The serve side arms the #695 handler-free
fast path on !entry.handlerLiveHoles, so a handler-live entry read back from
KV or the Vercel runtime cache silently replayed the handler layer — holes
that only a handler re-run can fill never filled on a HIT. Only the in-memory
store (which persists the entry object directly) round-tripped the flag.

Regression tests pin the round-trip in both stores (shown failing without the
envelope fix: 2 failed; passing with it: 52/52).
Post-build (buildApp post) phase reuses the buildStart temp server, seeds an
in-realm prerender store from the retained phase-A Flight payloads, and runs
the shared capture core (deriveShellCaptureContext + captureAndStoreShell)
per Prerender+ppr candidate. The SSR half is composed from the temp server's
SSR environment runner with the built client entry as bootstrap; production-
hashed client references bridge to dev refKeys via a wrapped
__vite_rsc_client_require__. Entries are stamped with the MAIN build's
version (version plugin api.getBuildVersion).

Validated on e2e/test-app /pp/alpha + /pp/beta: prelude carries the
prerendered article, the slot-loader hole fallback, and the built bootstrap
URL; postponed state present; first attempt lands (payload-settlement gate
from #697 inherited).
…ifest read-through (#699)

Build side: the shell prerender phase (buildApp post) stages one
ShellCacheEntry per Prerender+ppr URL as __ps-*.js asset modules with a lazy
__shell-manifest.js injected into the built RSC entry, mirroring the
prerender payload manifest (worker handles every request; no static file
serving). Captures that are refused, produce no shell, or never replayed the
prerender store are SKIPPED loudly — the route keeps runtime capture.

Serve side: on a runtime shell-store MISS, lookupBuildShell
(rsc/shell-build-manifest.ts) serves the baked entry through the SAME
serveShellHit — first request after deploy is a HIT with zero runtime
capture. Gates: search-less requests only, reactVersion/buildVersion,
payload integrity, and tag-invalidation markers. ppr.ttl drives staleness
only (recapture = the SWR upgrade path); buildVersion retires entries on
deploy.

Tag eviction without tombstones: new optional store method
isTagsInvalidatedSince(tags, sinceMs) — CF wraps its existing KV tag-marker
check; the memory store records per-tag invalidation instants; the Vercel
store writes its own tm-family markers on invalidateTags (the platform's
expireTag deletes entries and keeps no queryable history). A tagged build
entry on a store without the method is not served (warned once).

Manifest keys are pathname-only: router $$id hashes filePath:lineNumber of
the TRANSFORMED source, so temp-realm ids never match the shipped worker's —
pathname collisions across routers are declined at build instead.

Verified on the built test-app via vite preview: first-ever request to
/pp/alpha serves x-rango-shell: HIT with the build-frozen prelude; the slot
loader streams live (seq advances per request).
…ell HIT

Dev has no build manifest, so the read-through's dev branch fetches
/__rsc_shell (new dev-server endpoint mirroring /__rsc_prerender): the
endpoint runs the SAME captureShellForBuild core in the dev realms — main
server envs on the Node preset, the shared temp Node server on Cloudflare
(which now serves the REAL virtual SSR entry for 'virtual:entry-ssr' instead
of the discovery stub, lazily) — and returns the entry JSON, memoized per
router HMR generation AND caller version (a client-module edit bumps the
version without rotating the router instance). The endpoint is policy-free:
the serve gate sends the resolved ttl/swr/tags/version. A /__rsc_prerender
pre-flight warms the payload memo the capture's dev store fetch hits and
refuses non-prerenderable routes, so a live-handler render can never be
served as a baked shell. The read-through only arms for trie pr-flagged
routes (production's exact candidate set) and its fetch is deadline-bounded
(the #697 wedge lesson).

Verified on the test-app dev server: the FIRST document request to /pp/alpha
serves x-rango-shell: HIT with the frozen article + hole fallback; the slot
loader streams live and advances per request.
…tag eviction (dev + production, test-app + cloudflare-basic)

New assertions ride the #697 composition fixtures on BARE pathnames (build
entries cover the search-less URL only, so the existing ?probe= tests keep
exercising the runtime-capture lanes untouched): the FIRST request serves
x-rango-shell: HIT with the frozen prelude; loaders stay live across
baked-entry HITs; updateTag rejects the baked entry via the store tag
markers and the runtime capture takes over. Eviction gets a dedicated
route+tag per app (/pp-evict, /ppr-shell/prerendered-evict) so its
updateTag cannot blast sibling entries under fullyParallel dev runs; a
dedicated 'warm' slug in a beforeAll absorbs the dev on-demand capture's
cold-graph cost so the strict first-request assertions never race CI
cold-start physics. Dev /__rsc_shell fetch bound raised to 20s (the
response IS an inline capture: attempt + in-place retry ~10.5s).

route-mismatch diagnostics: captureShellForBuild reports what a swept
router actually matched; the SHELL SKIP line names it.

Local runs: prerender-ppr 7/7 dev + 7/7 production (test-app); ppr-shell
24/24 production + 24/24 dev (cloudflare-basic; one historical
capture-physics flake on /ppr-settled recurred once and passed in
isolation and on re-run). check:e2e-bucketing OK; *.gen.ts regenerated in
both apps.
Unit tests: isTagsInvalidatedSince on all three stores (memory per-tag
instants incl. marker-without-index-bucket; CF wrapper over the KV tag
markers; Vercel tm-family markers surviving expireTag) and the
lookupBuildShell serve gates (fresh/stale, search skip, version mismatch,
integrity, tag eviction, tagged-entry decline on marker-less stores, dev
on-demand branch incl. the pr-flag gate and endpoint-failure degrade).

Docs: prerender-api-design.md gains the Build-time PPR shells section
(build flow, runtime read-through, lifecycle semantics table, dev mode) +
key-files rows; shell-fast-path.md marks producer B shipped; internal
feature-map/feature-file-map gain the producer B rows.

Userland-primitives note: the feature adds no primitive-reachable API (the
ppr path option is unchanged; producer B is build/serve infrastructure), so
coverage is unit + dev/production e2e rather than a new testing-primitive
extension.
@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@701

commit: c75636b

…hip, hot-path memo

Reuse: writeBuildAssetModule extracted from stageBuildAssetModule (the shell
phase writes the same content-hashed asset modules directly into the final
RSC assets dir); producer B imports producer A's delay +
SHELL_CAPTURE_RETRY_DELAY_MS instead of duplicating them; the dev
/__rsc_shell endpoint requires ttl instead of hardcoding a second copy of
the 300s default (the caller always sends it — the endpoint is policy-free).

Simplification: dead buildShellManifestKey re-export dropped; the phase
stages the exact BuildShellEntry type the read-through consumes (type-only
import — the producer/consumer record contract can no longer drift); the
runtime-hit and build-hit serve blocks in rsc-rendering collapse into one
serveHit funnel; shellPhaseTempServer typed ViteDevServer|null; local
readSeq helper in the CF liveness test.

Altitude: the kept temp server + deferred buildEnv pair now has exactly two
teardown owners — the buildApp post finally (success) and buildEnd (aborted
build) — with runShellPrerenderPhase a pure producer that restores the
globals it installs, including the previously-leaked
__vite_rsc_client_require__ wrapper.

Efficiency: lookupBuildShell memoizes the version+integrity verdict per
content-hashed spec (a fresh build hit never populates the runtime store, so
the read-through is the steady state — the per-request full-prelude base64
decode is gone), returns before the searchParams sort when no source exists,
and the dev endpoint checks its memo before paying the /__rsc_prerender
pre-flight round-trip on every navigation.

Skipped (noted): sharing the manifest-module codegen/injection with
bundle-postprocess (refactor of unchanged code for a five-line shape) and
overlapping the Vercel invalidateTags marker writes with expireTag (failure
attribution interplay on a cold path).

Verified: typecheck, test:unit:all, lint, format green; prerender-ppr 7/7
dev + 7/7 production; cloudflare-basic ppr-shell 24/24 dev + 24/24
production.
@ivogt
ivogt force-pushed the feat/ppr-producer-b branch from 40b6cac to c75636b Compare July 6, 2026 11:45
@ivogt
ivogt merged commit f63db7c into main Jul 6, 2026
34 checks passed
@ivogt
ivogt deleted the feat/ppr-producer-b branch July 6, 2026 12:28
ivogt added a commit that referenced this pull request Jul 6, 2026
…lay is source of truth, shells converge via tags

A prerendered ppr route now carries two build artifacts (#699/#701): the
B-segment payload and a build-frozen document shell. Refreshing the overlay
alone would serve old committed prelude HTML over new Flight data. The design
now pins: shells are derived caches replayed from the prerender store, refresh
must write the route's tag markers so shells evict/refuse and recapture from
the overlay, tagless onDemand+ppr routes converge only on ppr.ttl (build-time
warning), overlay keys reuse the existing @rangojs/router:version stamp, and
the routerId discriminator must be realm-stable (never $$id). Adds the
document/pipeline two-lane serving flow, the invalidation family matrix, and
shell-convergence test requirements.
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.

PPR producer B: emit the shell entry at BUILD time for prerendered routes (Prerender + ppr = zero runtime capture)

1 participant