Repository navigation
feat(ppr): producer B — emit the shell entry at build time for prerendered routes (#699) - #701
Merged
Merged
Conversation
…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).
…le, phase B scaffold
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.
commit: |
…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
force-pushed
the
feat/ppr-producer-b
branch
from
July 6, 2026 11:45
40b6cac to
c75636b
Compare
6 tasks done
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.
This was referenced Jul 6, 2026
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.
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 asx-rango-shell: MISS: the complete Flight payload existed sincevite 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 completeShellCacheEntryatvite build(producer B), so the first-ever request servesx-rango-shell: HITwith the frozen prelude and live streaming loader holes. Dev gets the same semantics via on-demand production.Before vs. after
End-to-end consumer example
vite buildnow logsSHELL 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, andupdateTag("docs")evicts the baked entries.Semantics
x-rango-shell: HITfrom the baked manifest entry, served through the sameserveShellHitas a captured entryppr.ttlelapsesupdateTag()on the entry's tagisTagsInvalidatedSince(tags, entry.createdAt), new optional store method)SHELL SKIPbuild line; the route keeps runtime-capture semantics — never a wrong-lane bakeisTagsInvalidatedSince/__rsc_shellon-demand endpoint (below); non-prerenderedpprroutes keep runtime capture unchangedallReadyCode changes
Build (producer B):
vite/discovery/prerender-collection.ts+router/prerender-match.ts:matchForPrerendersurfaces the matched route'spproption; collection records shell candidates and retains Flight payload JSONs onDiscoveryState.vite/discovery/shell-prerender-phase.ts(new): abuildApppost 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-*.jsassets + a lazy__shell-manifest.jsand injectsglobalThis.__loadShellManifestModuleinto the built RSC entry (mirroring the prerender manifest; the worker handles every request — no static file serving). Manifest keys are pathname-only: router$$ids hashfilePath:lineNumberof 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 (createRequestContextover the build env — no ambient identity, so the identity guard is trivially satisfied). Reuses producer A verbatim viaderiveShellCaptureContext(extracted fromattemptCaptureinrsc/shell-capture.ts— mask funnel, liveness bookkeeping, snapshot recording, implicit doc-cache scope; one implementation, two producers) + the existingcaptureAndStoreShell. The capture'smatch()re-enterswithCacheLookupand 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 afetchedKeysassertion refuses any capture that never consulted the store. Entries are stamped with the MAIN build's version (newapi.getBuildVersion()on the version plugin — the temp server's own stamp would fail the serve gate forever).__vite_rsc_client_require__using the samecomputeProductionHashreverse 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):lookupBuildShellon a runtime shell-store MISS inrsc-rendering.ts, serving through the sameserveShellHit. Gates: search-less request,reactVersion/buildVersion, payload integrity, tag markers. Every fault degrades to a MISS.cache/types.ts+ three stores: new optionalisTagsInvalidatedSince(tags, sinceMs)— CF wraps its existing KV tag-marker check; the memory store records per-tag invalidation instants; the Vercel store writes its owntm-family markers oninvalidateTags(the platform'sexpireTagdeletes entries and keeps no queryable history; marker writes are strict so a failed one rejects like a failedexpireTag).Dev:
/__rsc_shellendpoint (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, whosevirtual:entry-ssrnow 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_prerenderpre-flight warms the payload memo and refuses non-prerenderable routes. The read-through's dev branch arms only for triepr-flagged routes and its fetch is deadline-bounded at 20s (the response IS an inline capture: attempt + in-place retry).Bundled fix —
handlerLiveHolesdropped by persistent stores (pre-existing #695 bug): bothKVShellEnvelopeandVercelShellEnvelopeomitted 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
isTagsInvalidatedSinceon all three stores,lookupBuildShellserve gates incl. the dev branch — 88 tests across 4 files;pnpm run test:unit:allgreen.prerender-ppr.test.ts(test-app) andppr-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.tsregenerated in both apps.vite previewfirst-ever request to/pp/alpha= HIT with built bootstrap URL in the prelude; dev server first request = HIT via/__rsc_shell.Notes
pprpath option is unchanged; producer B is build/serve infrastructure), so coverage is unit + dev/production e2e rather than a testing-primitive extension.$$idhashesfilePath:lineNumberof TRANSFORMED source, so dev-transform and build-transform ids differ for the samecreateRouter(). 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.pprroute 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.bootstrapModulesform) 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.buildApppost hook runs beforevercel-output.ts's (plugin order), so.vercel/outputpicks up the shell manifest; not yet exercised by a vercel e2e.