Repository navigation
fix(router): a Prerender + ppr shell HIT hydrates with its loader pushes (#1057) - #1061
Merged
Merged
Conversation
…ld-time artifact A Prerender route rendered its handler live under serveShellRequest, so a Prerender + ppr route captured a doc record like a plain ppr route and no test could reach the prerender-served shell production serves. serveShellRequest now bakes the URL's artifact with router.matchForPrerender (as the dev /__rsc_prerender endpoint does) and serves it through the production prerender store over globalThis.__loadPrerenderManifestModule, as the build-time shell phase installs it. cache-lookup gains an internal setPrerenderStoreForTests seam; resetShellTestState drops baked artifacts.
…hes (#1057) On a route that combines Prerender with ppr, a settled handle push from an ssr: false loader was in the shell's HTML but not in the data a HIT hydrated with: React #418 / "Hydration failed" on every HIT, and the element never came back. The prerender store supplies such a route's handler layer, so its capture writes no doc record, and the build-time prerender entry holds no loader push; a promise-free loader is served from its pin and does not run. The capture now stores the loader-owned handle arrays its prelude rendered in a `handles` snapshot record (the doc record's handles + handleOwners format, exempt from maxSnapshotBytes), and yieldFromStore restores it after the prerender store's handles with the doc record's rule (setRecordAuthority from loaderPins, then restoreHandles), before the render barrier freezes the document snapshot. An entry without the record (v0.21) serves as before. On the Cloudflare preset the dev /__rsc_shell capture ran in a temp Node realm without __PRERENDER_DEV_URL, rendered the Prerender handler live into a doc record the HIT never reads, and lost the same pushes: the plugin now sets the global for that realm, so the capture replays the prerender store as production's build phase does. Tests: serveShellRequest suite for Prerender + ppr (settled push, promise push, maxSnapshotBytes, a 0.21 entry, plain ppr control); unit tests next to handle-snapshot, shell-snapshot, shell-capture and cache-lookup; dev and production browser e2e in the router test-app and cloudflare-basic, for a runtime-captured shell and the build-time shell.
…r-loader-pushes # Conflicts: # packages/rangojs-router/CHANGELOG.md
…r-loader-pushes # Conflicts: # packages/rangojs-router/CHANGELOG.md # packages/rangojs-router/e2e/shell-push-ownership.test.ts # packages/rangojs-router/e2e/test-app/src/router.named-routes.gen.ts # packages/rangojs-router/e2e/test-app/src/urls/shell-push-ownership.tsx # tests/cloudflare-basic/e2e/ppr-push-ownership.test.ts # tests/cloudflare-basic/src/pages/ppr-push-ownership.tsx # tests/cloudflare-basic/src/router.named-routes.gen.ts # tests/cloudflare-basic/src/urls.tsx
The dev and preview servers both persisted to .wrangler/state. In a local run with both projects, the dev "updateTag evicts the build-time shell" test wrote the ppr-pp-evict-shell tag marker into the shared KV after the build, and the production twin of that test then found its build-time shell already evicted (MISS where it expects HIT). Reproduced with --workers=1 on origin/main (5b7d02a) as well. CI runs each project in its own job, so it never shared the state there.
commit: |
ivogt
added a commit
that referenced
this pull request
Oct 4, 2026
A build bakes an on-demand route only for the params getParams() lists, so a refresh-only page is served from its prerender store alone; baking every URL (the #1061 emulation) ran the producer per request and hid the overlay.
9 tasks done
ivogt
added a commit
that referenced
this pull request
Oct 5, 2026
* docs(router): add on-demand prerender design doc Proposes ISR-style prerender refresh from a running app: a writable durable overlay in front of the bundled prerender manifest, a router.prerender() trigger callable from fetch, cron, queues, workflows, webhooks, and server actions, and an explicit per-route onDemand opt-in on Prerender(). Key decisions pinned in the doc: - Requestless producer: triggers supply env/ctx capabilities, never request state; cache-taint-style guards map personalization to a skipped result. - Refresh always renders and replaces; onlyIfStale is the cron-sweep opt-in. - TTL is soft staleAt metadata, never hard store expiry; a stale overlay hit still serves (the overlay is always newer than the build artifact). - V1 invalidation is mark-stale, not delete (deleting would re-expose older bundled-manifest content); tombstones deferred. - The retained producer is never invoked by the request pipeline; a miss behaves exactly like today's pr + miss. - Versioned stored-entry envelope with buildId scoping and verify-on-read params (DJB2 collision guard). - Producer threads the #587 throwOnError flag so a failed refresh cannot bake a frozen error page. * feat(router): on-demand (ISR-style) prerender Add router.prerender() to refresh prerendered route payloads from a running app (fetch, cron, queue, webhook, server action), backed by a writable durable overlay in front of the bundled prerender manifest. - prerender router option (env-resolved per request, same union shape as cache) - Prerender(..., { onDemand }) opt-in -> od trie flag + producer-code retention - router.prerender / .many / .invalidateTags with an inspectable PrerenderResult - requestless producer + personalization guard (reads map to skipped-personalized) - serve-path overlay read (overlay -> bundled manifest -> miss) + SWR scheduling - versioned stored-entry envelope, build-scoped keys, verify-on-read collision guard - in-memory store (@rangojs/router/prerender) + Cloudflare KV adapter (@rangojs/router/prerender/cloudflare); testing fake on @rangojs/router/testing - dev + production e2e in the node test-app and cloudflare-basic * test(router): accept synthetic handler ids in bundle guard * fix(router): harden on-demand prerender per review findings - gate retained onDemand producers off live production requests (gateOnDemandProducer in path-helper): an overlay+manifest miss on a plain Prerender(..., { onDemand }) route keeps the pr-miss 404 contract instead of running the producer in-request with the guard unarmed - fail the build when a manifest-od route's producer is not statically detected by the bundle scan (onDemandHandlerIds cross-check in postprocessBundle); detect quoted onDemand keys - surface only personalization errors from producer Flight serialization (boundary-rendering routes can refresh again; matches build-time bake) - trigger: stop many({ throwOnError }) batches on first failure; map prerender-factory throws to a no-store result; dev-warn on no-op invalidateTags; document the onRevalidate stampede footgun - serve path: skip guaranteed-miss overlay reads on intercept navigations - dev-warn when a route sets both ppr and onDemand (ppr is inert) - docs: client staleness window after refresh (amplified by default viewport prefetch), PPR shell-lane exclusion, KV read cost, disjoint tag namespaces - tests: 10 new units (many-abort, factory throw, retention cross-check, quoted keys); dev+prod e2e in test-app and cloudflare-basic for the plain-od 404 gate (proven red-before-green), frozen payload with fresh loaders, onlyIfStale + tag invalidation (first KV end-to-end), swr onRevalidate scheduling; bundle retention assertions in both apps * feat(router): drive onDemand producer retention from the route manifest Replace the textual onDemand scan in extractHandlerExportsFromChunk with a lookup in state.onDemandHandlerIds — the $$id set discovery derives from the evaluated routes module, the same source that sets the runtime od trie flag. Retention and serve now share one derivation and cannot disagree, so: - any truthy onDemand spelling works (literal, spread, imported const); the static-literal restriction is removed from JSDoc, types, and docs - the value regex, quoted-key handling, and the postprocessBundle retention cross-check are deleted (divergence is impossible by construction) - an id absent from any chunk keeps retain-by-default (bundle bytes, never a broken refresh) Contract pin: the test-app SWR fixture now passes its options as a shared const (Prerender(gen, fn, SWR_OD_OPTIONS)) — the exact spelling the old regex silently evicted — and its full production e2e flow (refresh, stale serve, swr scheduling) passes. Unit tests rewritten map-driven; cross-check suite removed with the mechanism. * fix(router): on-demand producer carries the router's cache versions; regenerate cloudflare-basic urls.gen.ts The requestless producer context had no _versions, so cache stores built inside a refresh fell back to the whole-build pair and logged the outside-a-request warning when routers have versions of their own. * fix(router): on-demand overlay serves action re-renders and keys on the data version A server action on a plain Prerender(..., { onDemand }) page skipped the overlay: an overlay-only param 404'd on the re-render in production and a baked one served the older build payload. The overlay is now read on actions (Passthrough routes still re-render live), and an overlay hit on an action re-sends the route's own segments, so router.prerender() inside the action shows the entry it just stored. The overlay key now carries the router's data version instead of the document version, so a client-only deploy keeps refreshed pages. The trigger and the request handler resolve it through one call (resolvePrerenderVersion), the trigger per call. Also: onRevalidate is scheduled once per stale key per isolate while one is in flight; the trigger and the producer gate share isDevEnvironment(); dev warns when tags are marked stale with no onRevalidate configured; ppr + onDemand throws at route definition instead of a dev-only warning. Tests: dev + production e2e in test-app and cloudflare-basic (action on an overlay-only page; router.prerender() inside an action); an rsc dogfood (router.prerender() then serveShellRequest) pinning serve, loaders fresh, skipped-personalized, one key version on both sides, getCacheVersions() inside a refresh, and onRevalidate dedup. * refactor(router)!: settle the on-demand prerender API names Pre-release renames, no deprecations: - PrerenderConfig is { store, ttl?, onRevalidate? }: `swr` is gone (the presence of onRevalidate is the stale-while-revalidate opt-in) and `defaultTtl` is `ttl` (a route's onDemand.ttl overrides it). - router.prerender.invalidateTags() is router.prerender.markStale(), and the optional store method is markStale(): it marks entries stale and keeps serving them, unlike updateTag()/revalidateTag(), which purge. - PrerenderKey.buildId, the stored entry's meta.buildId and the internal ResolvedPrerender.buildId are `version`: the owning router's cache version, opaque to app code. The serialized key is prerender:{routerId}:{version}:{routeName}:{paramHash}. - onDemand.tags accepts string[] as well as (target) => string[]. - @rangojs/router/prerender no longer exports ResolvedPrerender (internal) or PrerenderRuntimeBase (markStale takes { env, ctx? } inline). - The store contract JSDoc tells store authors to key off key.version and never call getCacheVersions(): set() runs outside the producer's request. Fixtures and e2e follow the renames; the test-app fixture comment on the typed object target now gives the real reason it does not typecheck there. * refactor(router): prerender stores are plain get/set; the router composes and verifies the envelope Each store re-ran the envelope's verify-on-read (version and the params collision guard) inside its own get(), and composeStoredEntry / isStoredEntryValidFor were not exported, so a third-party store had to reimplement the collision guard by hand or silently skip it. The contract is now get(key) -> PrerenderStoredEntry | null, set(key, stored), optional delete(key) and markStale(tags). The trigger composes the envelope; the serve path and the trigger's onlyIfStale read verify every store's answer (shape, key version, params). A store may still lower staleAt from its own tag markers, as the KV store does. PrerenderLookupMeta and PrerenderSetOptions leave the public surface; serializePrerenderKey stays. The memory store's markStale never moves an earlier staleAt later. New rsc dogfood case: a naive store that answers every key with the last write does not serve one param set's entry under another's URL (red without the serve-path verification). * docs(router): on-demand prerender in the skills, design doc and feature map The skills said nothing about on-demand prerender, and the prerender skill's "Tag invalidation does not reach prerendered segments" section pointed only at the runtime cache. Now: - skills/prerender: an "On-demand refresh (ISR)" section with the opt-in, the store config, the trigger, a serve table (fresh / stale / build entry / miss for Prerender and Passthrough, dev and production, plus action re-renders) and the rules: soft ttl, per-isolate onRevalidate dedup, separate tag namespace that updateTag()/revalidateTag() never reach, markStale only marks, requestless producer with the live env, params outside getParams(), notFound() keeps the old entry, intercept variants not refreshed, client prefetch staleness, per-version keys, custom stores, testing. - skills/cloudflare: the KV store, scheduled sweep and queue consumer. - skills/deployment-caching: the on-demand row, Workers read cost, no Vercel store in v1. - skills/migrate-nextjs: revalidatePath / revalidateTag / time-based ISR mapped to router.prerender() / markStale() / onDemand.ttl. - design doc, prerender-api-design and feature map follow the API renames, the get/set store contract, the data-version key and action re-renders. tools/check-docs-api.mjs now finds non-generic interface declarations, which a `type` import in a skill names (WritablePrerenderStore). * refactor(router): on-demand prerender cleanup pass - onRevalidate dedup entries age out after IN_FLIGHT_LEADER_MAX_WAIT_MS, are skipped under ctx.build, and are cleared by resetShellTestState() - the request handler resolves the prerender key version per request, like the trigger does per refresh (once per .many() batch) - ppr + onDemand throws before the forRoute early return - shared store helpers lowerStoredEntryStaleAt and readVerifiedStoredEntry; KV get drops duplicate shape checks and skips marker reads for stale entries - trigger tags go through normalizeTagList (trim, drop empties, de-duplicate) - remove the unused PrerenderKey.intercept slot and :i suffix - dev warnings fire once per trigger; passthrough probe computed once per request; history-narrating comments cut * feat(router): onRevalidate target feeds router.prerender() without a cast PrerenderTargetObject carries a type-only brand and PrerenderTarget accepts it, so onRevalidate: (target, env) => router.prerender(target, { env }) typechecks on a router with named routes, queue JSON round trip included. A wrong route name or params in a hand-written target is still a type error; pinned in tests/cloudflare-basic/src/prerender-target-types.ts. * refactor(router)!: router.prerender({ env, ctx }) returns a runner Bind env and ctx once, then call the runner per target. Per-call options (onlyIfStale, throwOnError, concurrency) stay per call; the runtime type now holds only { env, ctx? }. onRevalidate is unchanged. * feat(router): onRevalidate receives the stale request's ctx The documented one-liner binds the runner with it: onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target). * fix(testing): serveShellRequest bakes no artifact for an on-demand route A build bakes an on-demand route only for the params getParams() lists, so a refresh-only page is served from its prerender store alone; baking every URL (the #1061 emulation) ran the producer per request and hid the overlay.
ivogt
added a commit
that referenced
this pull request
Oct 5, 2026
* docs(router): add on-demand prerender design doc Proposes ISR-style prerender refresh from a running app: a writable durable overlay in front of the bundled prerender manifest, a router.prerender() trigger callable from fetch, cron, queues, workflows, webhooks, and server actions, and an explicit per-route onDemand opt-in on Prerender(). Key decisions pinned in the doc: - Requestless producer: triggers supply env/ctx capabilities, never request state; cache-taint-style guards map personalization to a skipped result. - Refresh always renders and replaces; onlyIfStale is the cron-sweep opt-in. - TTL is soft staleAt metadata, never hard store expiry; a stale overlay hit still serves (the overlay is always newer than the build artifact). - V1 invalidation is mark-stale, not delete (deleting would re-expose older bundled-manifest content); tombstones deferred. - The retained producer is never invoked by the request pipeline; a miss behaves exactly like today's pr + miss. - Versioned stored-entry envelope with buildId scoping and verify-on-read params (DJB2 collision guard). - Producer threads the #587 throwOnError flag so a failed refresh cannot bake a frozen error page. * feat(router): on-demand (ISR-style) prerender Add router.prerender() to refresh prerendered route payloads from a running app (fetch, cron, queue, webhook, server action), backed by a writable durable overlay in front of the bundled prerender manifest. - prerender router option (env-resolved per request, same union shape as cache) - Prerender(..., { onDemand }) opt-in -> od trie flag + producer-code retention - router.prerender / .many / .invalidateTags with an inspectable PrerenderResult - requestless producer + personalization guard (reads map to skipped-personalized) - serve-path overlay read (overlay -> bundled manifest -> miss) + SWR scheduling - versioned stored-entry envelope, build-scoped keys, verify-on-read collision guard - in-memory store (@rangojs/router/prerender) + Cloudflare KV adapter (@rangojs/router/prerender/cloudflare); testing fake on @rangojs/router/testing - dev + production e2e in the node test-app and cloudflare-basic * test(router): accept synthetic handler ids in bundle guard * fix(router): harden on-demand prerender per review findings - gate retained onDemand producers off live production requests (gateOnDemandProducer in path-helper): an overlay+manifest miss on a plain Prerender(..., { onDemand }) route keeps the pr-miss 404 contract instead of running the producer in-request with the guard unarmed - fail the build when a manifest-od route's producer is not statically detected by the bundle scan (onDemandHandlerIds cross-check in postprocessBundle); detect quoted onDemand keys - surface only personalization errors from producer Flight serialization (boundary-rendering routes can refresh again; matches build-time bake) - trigger: stop many({ throwOnError }) batches on first failure; map prerender-factory throws to a no-store result; dev-warn on no-op invalidateTags; document the onRevalidate stampede footgun - serve path: skip guaranteed-miss overlay reads on intercept navigations - dev-warn when a route sets both ppr and onDemand (ppr is inert) - docs: client staleness window after refresh (amplified by default viewport prefetch), PPR shell-lane exclusion, KV read cost, disjoint tag namespaces - tests: 10 new units (many-abort, factory throw, retention cross-check, quoted keys); dev+prod e2e in test-app and cloudflare-basic for the plain-od 404 gate (proven red-before-green), frozen payload with fresh loaders, onlyIfStale + tag invalidation (first KV end-to-end), swr onRevalidate scheduling; bundle retention assertions in both apps * feat(router): drive onDemand producer retention from the route manifest Replace the textual onDemand scan in extractHandlerExportsFromChunk with a lookup in state.onDemandHandlerIds — the $$id set discovery derives from the evaluated routes module, the same source that sets the runtime od trie flag. Retention and serve now share one derivation and cannot disagree, so: - any truthy onDemand spelling works (literal, spread, imported const); the static-literal restriction is removed from JSDoc, types, and docs - the value regex, quoted-key handling, and the postprocessBundle retention cross-check are deleted (divergence is impossible by construction) - an id absent from any chunk keeps retain-by-default (bundle bytes, never a broken refresh) Contract pin: the test-app SWR fixture now passes its options as a shared const (Prerender(gen, fn, SWR_OD_OPTIONS)) — the exact spelling the old regex silently evicted — and its full production e2e flow (refresh, stale serve, swr scheduling) passes. Unit tests rewritten map-driven; cross-check suite removed with the mechanism. * fix(router): on-demand producer carries the router's cache versions; regenerate cloudflare-basic urls.gen.ts The requestless producer context had no _versions, so cache stores built inside a refresh fell back to the whole-build pair and logged the outside-a-request warning when routers have versions of their own. * fix(router): on-demand overlay serves action re-renders and keys on the data version A server action on a plain Prerender(..., { onDemand }) page skipped the overlay: an overlay-only param 404'd on the re-render in production and a baked one served the older build payload. The overlay is now read on actions (Passthrough routes still re-render live), and an overlay hit on an action re-sends the route's own segments, so router.prerender() inside the action shows the entry it just stored. The overlay key now carries the router's data version instead of the document version, so a client-only deploy keeps refreshed pages. The trigger and the request handler resolve it through one call (resolvePrerenderVersion), the trigger per call. Also: onRevalidate is scheduled once per stale key per isolate while one is in flight; the trigger and the producer gate share isDevEnvironment(); dev warns when tags are marked stale with no onRevalidate configured; ppr + onDemand throws at route definition instead of a dev-only warning. Tests: dev + production e2e in test-app and cloudflare-basic (action on an overlay-only page; router.prerender() inside an action); an rsc dogfood (router.prerender() then serveShellRequest) pinning serve, loaders fresh, skipped-personalized, one key version on both sides, getCacheVersions() inside a refresh, and onRevalidate dedup. * refactor(router)!: settle the on-demand prerender API names Pre-release renames, no deprecations: - PrerenderConfig is { store, ttl?, onRevalidate? }: `swr` is gone (the presence of onRevalidate is the stale-while-revalidate opt-in) and `defaultTtl` is `ttl` (a route's onDemand.ttl overrides it). - router.prerender.invalidateTags() is router.prerender.markStale(), and the optional store method is markStale(): it marks entries stale and keeps serving them, unlike updateTag()/revalidateTag(), which purge. - PrerenderKey.buildId, the stored entry's meta.buildId and the internal ResolvedPrerender.buildId are `version`: the owning router's cache version, opaque to app code. The serialized key is prerender:{routerId}:{version}:{routeName}:{paramHash}. - onDemand.tags accepts string[] as well as (target) => string[]. - @rangojs/router/prerender no longer exports ResolvedPrerender (internal) or PrerenderRuntimeBase (markStale takes { env, ctx? } inline). - The store contract JSDoc tells store authors to key off key.version and never call getCacheVersions(): set() runs outside the producer's request. Fixtures and e2e follow the renames; the test-app fixture comment on the typed object target now gives the real reason it does not typecheck there. * refactor(router): prerender stores are plain get/set; the router composes and verifies the envelope Each store re-ran the envelope's verify-on-read (version and the params collision guard) inside its own get(), and composeStoredEntry / isStoredEntryValidFor were not exported, so a third-party store had to reimplement the collision guard by hand or silently skip it. The contract is now get(key) -> PrerenderStoredEntry | null, set(key, stored), optional delete(key) and markStale(tags). The trigger composes the envelope; the serve path and the trigger's onlyIfStale read verify every store's answer (shape, key version, params). A store may still lower staleAt from its own tag markers, as the KV store does. PrerenderLookupMeta and PrerenderSetOptions leave the public surface; serializePrerenderKey stays. The memory store's markStale never moves an earlier staleAt later. New rsc dogfood case: a naive store that answers every key with the last write does not serve one param set's entry under another's URL (red without the serve-path verification). * docs(router): on-demand prerender in the skills, design doc and feature map The skills said nothing about on-demand prerender, and the prerender skill's "Tag invalidation does not reach prerendered segments" section pointed only at the runtime cache. Now: - skills/prerender: an "On-demand refresh (ISR)" section with the opt-in, the store config, the trigger, a serve table (fresh / stale / build entry / miss for Prerender and Passthrough, dev and production, plus action re-renders) and the rules: soft ttl, per-isolate onRevalidate dedup, separate tag namespace that updateTag()/revalidateTag() never reach, markStale only marks, requestless producer with the live env, params outside getParams(), notFound() keeps the old entry, intercept variants not refreshed, client prefetch staleness, per-version keys, custom stores, testing. - skills/cloudflare: the KV store, scheduled sweep and queue consumer. - skills/deployment-caching: the on-demand row, Workers read cost, no Vercel store in v1. - skills/migrate-nextjs: revalidatePath / revalidateTag / time-based ISR mapped to router.prerender() / markStale() / onDemand.ttl. - design doc, prerender-api-design and feature map follow the API renames, the get/set store contract, the data-version key and action re-renders. tools/check-docs-api.mjs now finds non-generic interface declarations, which a `type` import in a skill names (WritablePrerenderStore). * refactor(router): on-demand prerender cleanup pass - onRevalidate dedup entries age out after IN_FLIGHT_LEADER_MAX_WAIT_MS, are skipped under ctx.build, and are cleared by resetShellTestState() - the request handler resolves the prerender key version per request, like the trigger does per refresh (once per .many() batch) - ppr + onDemand throws before the forRoute early return - shared store helpers lowerStoredEntryStaleAt and readVerifiedStoredEntry; KV get drops duplicate shape checks and skips marker reads for stale entries - trigger tags go through normalizeTagList (trim, drop empties, de-duplicate) - remove the unused PrerenderKey.intercept slot and :i suffix - dev warnings fire once per trigger; passthrough probe computed once per request; history-narrating comments cut * feat(router): onRevalidate target feeds router.prerender() without a cast PrerenderTargetObject carries a type-only brand and PrerenderTarget accepts it, so onRevalidate: (target, env) => router.prerender(target, { env }) typechecks on a router with named routes, queue JSON round trip included. A wrong route name or params in a hand-written target is still a type error; pinned in tests/cloudflare-basic/src/prerender-target-types.ts. * refactor(router)!: router.prerender({ env, ctx }) returns a runner Bind env and ctx once, then call the runner per target. Per-call options (onlyIfStale, throwOnError, concurrency) stay per call; the runtime type now holds only { env, ctx? }. onRevalidate is unchanged. * feat(router): onRevalidate receives the stale request's ctx The documented one-liner binds the runner with it: onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target). * fix(testing): serveShellRequest bakes no artifact for an on-demand route A build bakes an on-demand route only for the params getParams() lists, so a refresh-only page is served from its prerender store alone; baking every URL (the #1061 emulation) ran the producer per request and hid the overlay. * docs(router): design router.prerender() for every route, with red tests Design write-up for #1062, stacked on #640: router.prerender() keeps the requestless render for Prerender(..., { onDemand }) routes and warms every other route with a cookie-free GET through router.fetch's handler, where each runtime cache read misses and each write replaces the entry under the visitor's key. Covers the path table, the hook point per cache layer, the synthetic request and its in-process mark, the SegmentCacheStore.scope declaration, the result shape, answers to the open questions, the test plan, the build estimate, and the points where the decided design needs a correction. Red tests, no implementation: - src/testing/__tests__/prerender-warm.rsc-test.tsx (userland, through serveShellRequest and the public runner): 10 tests, all red. - src/cache/__tests__/store-scope.test.ts (scope of each shipped store): 5 tests, all red. The unit gate fails on these by design until the feature is built. * feat(cache): stores declare where their entries can be read SegmentCacheStore gains an optional `scope: "global" | "regional" | "local"` (CacheStoreScope): where an entry one request writes can be read by a later one. router.prerender() will warm a route only into a store shared beyond the place the call runs. - CFCacheStore: "global" with KV, "local" without (purge mode included). - VercelCacheStore: "regional" (the Runtime Cache is per region). - MemorySegmentCacheStore: "local". - A store that declares nothing is treated as "local". cache/store-scope.ts resolveWarmStoreScope is the gate's reading of a declaration. One exception lives there: under the Vite dev server a MemorySegmentCacheStore counts as "global", because that one process serves every request, so warming can be tried locally. Production reads the class's own declaration. Turns the 5 store-scope tests written with the design green. * feat(router): a warm request reads every runtime cache as a miss and forces its shell capture The request-side half of router.prerender() for every route (#1062): what a request does once it is marked as a warm. Nothing marks one yet; the runner that does follows. The mark is the Request object itself (a WeakMap in prerender/warm-request.ts, never a header, parameter or cookie a client could send). The handler reads it, puts the record on the request context (RequestContext._prerenderWarm) and uses the cache config the runner gated instead of resolving it again. Replace mode, one predicate (isWarmReplace) read where each layer already reads the request context, each taking its existing miss branch: - route cache(): CacheScope.lookupRouteDetailed, after enabled/condition() - loader cache(): executeLoaderData's getItem - "use cache": registerCachedFunction's lookup; the call also leads its own execution instead of joining an in-flight one - the ppr shell: shellServePlan reads neither the stored nor the build shell - the document cache and a response route's cache Every write goes through the unchanged write path and replaces the entry; nothing is evicted first. The layers count their writes into the record, and guardIdentityRead notes the surface it refuses. Guard split. A warm's capture is forced (ShellCaptureDescriptor.force): it bypasses the per-key in-flight dedup, the refused-capture backoff and skip-stored, which exist to stop a herd of visitors repeating work. It reads its caches normally (the predicate excludes _shellCaptureRun), so it replays what the warm just wrote, and waits longer on the write barrier. Every correctness guard decides as for a visitor: identity reads, the tag-invalidation write gates, render errors, size limits, cache(false) and condition(), the document cache's refusals, queue capacity. ShellCaptureDebugEvent gains `refusal` (ShellCaptureRefusal), set at each refuse exit. The handler also captures the async context each request enters with (captureRequestEntryContext, RequestContext._runAtRequestEntry): a warm called from inside a request is dispatched through it, outside the caller's AsyncLocalStorage scopes. Per request, because workerd refuses a snapshot outside the request that created it. * feat(router): router.prerender() warms any route that is not on-demand router.prerender() answered skipped-not-on-demand for every route that is not Prerender(..., { onDemand }). It now dispatches by route, after the match (#1062): - an on-demand route takes #640's requestless render into the prerender store, then one warm request on top of the new entry, when the app cache store is shared and an origin resolves (result.caches); - every other route is warmed: a cookie-free GET (accept: text/html only) of the URL a visitor requests, through router.fetch, marked in process, in replace mode. The runner drains the response, waits for the request's background work (the cache writes, the shell capture) and reports what was written. Store gate: a warm runs only when the app store's scope is "global" or "regional". A local or undeclared store returns skipped-store-not-shared before anything renders, and warns once per router in dev. Origin: the target's own, else `origin` on the binding (router.prerender({ env, ctx, origin })), else the origin of the request the runner is called from, else skipped-no-origin. The on-demand render never needs one. Search params: #640's rejection moved after the route match. A warm target keeps them (cache keys carry them); an on-demand target still rejects them. A hash or a reserved param (_rsc*, __no_cache) is unsupported on a warm. onlyIfStale on a warm is fill mode: the request reads its caches normally, so only what is cold or stale is rewritten, and zero writes is already-fresh. Result: every result past the match carries path: "on-demand" | "warm"; the warm path adds warmed, skipped-store-not-shared, skipped-no-origin, shell-not-stored and skipped-uncached, and `caches` (writes by store family, the shell's outcome and refusal, the document cache's outcome). skipped-not-on-demand is removed: #640 is unreleased. `key` and `tags` are on the on-demand variant only, so reading them narrows on `path`. A warm starts in a later millisecond than the call: a store refuses a shell whose capture started in the millisecond of an invalidation of one of its tags, which made `await updateTag(tag)` then a warm fail intermittently. cf-cache-store-warm-refresh.test.ts pins that sequence across two CFCacheStore instances sharing one fake KV: without an invalidation another colo keeps its own Cache API copy; after updateTag and the warm's write a colo with no copy reads the warmed entry from KV, and a colo holding the old copy stops serving it (it renders for itself: get() does not fall through to KV on an invalidated L1 hit). * feat(testing): router.prerender() warms under serveShellRequest A warm renders through router.fetch, whose handler is built with no loadSSRModule and reaches for import.meta.viteRsc.loadModule, which the rsc Vitest project cannot run. serveShellRequest already stubs exactly that one module for its own handlers. rsc/ssr-module-loader.ts holds the SSR module loader a handler created without loadSSRModule falls back to in tests; testing/serve-shell-request.ts installs its stub there when it loads. It is a leaf with no runtime imports because serve-shell-request.ts cannot import rsc/handler.ts at module scope (the handler binds build-only virtual modules). Everything else on a warm's path is production code: router.fetch, createRSCHandler, the match pipeline, the capture. Userland tests, through router.prerender() and serveShellRequest: - prerender-warm.rsc-test.tsx: the 10 tests written with the design, green with no change to a test body (the header comment no longer says they fail). - prerender-warm-layers.rsc-test.tsx (22): the same replace contract for a loader's own cache(), "use cache", the document cache and a response route; the key a warm writes under (search params, cache.searchParams, the host); the origin; a warm called from a route handler for a route no visitor has requested (a 404 without the request-entry context); updateTag then prerender from one handler; onlyIfStale; each refusal. * test(e2e): warming a route in a browser, dev and production, in both apps Six shared bodies in tests/shared-e2e (PrerenderWarmFixture), run by packages/rangojs-router/e2e/prerender-warm.test.ts (test-app, Node) and tests/cloudflare-basic/e2e/prerender-warm.test.ts (CFCacheStore over KV), each with a dev and a (production) describe: - warming a ppr route makes the next document request x-rango-shell: HIT, and it hydrates clean; - a warm replaces a stored shell: the next document shows the newer render; - a warm replaces a route cache() record a visitor's request wrote; - a route that reads cookies() reports skipped-personalized and stays a MISS; - a route handler calling router.prerender({ env }) with no origin warms on the request's origin, an on-demand and a plain target in one batch; - onlyIfStale leaves a warmed shell alone (already-fresh). Fixtures: /warm/shell, /warm/cached, /warm/personal, a trigger route and a generation bump in each app. Each test owns a ?probe=, and so its cache keys. The test-app's cache store is a memory store, refused in production. Its router.tsx now declares it shared (a subclass with scope "global": one process serves the app), which is what lets the production describe warm. Only the warm gate reads the declaration. Generated route files: router.named-routes.gen.ts in both apps, and urls.gen.ts in both, regenerated with `rango generate` (cloudflare-basic statically, the test-app with --runtime). Besides the five new routes each, the two urls.gen.ts files pick up routes earlier changes had not regenerated (7 in cloudflare-basic, 113 in the test-app). Red on #640's source (test-app, dev): all six fail, the plain targets with {"ok":false,"status":"skipped-unsupported-target"}. * docs(router): router.prerender() for every route - skills/prerender: "Warm any route before traffic": what one call does by route, which stores are shared, the origin, the anonymous request, the result statuses and `caches`, refreshing changed content with updateTag then prerender, the limits (router.fetch only, document records only, one location at a time), and how to test it. The on-demand section drops skipped-not-on-demand and says an on-demand route is also warmed. - skills/caching: filling a cache before traffic, and `scope` on a custom store. skills/cloudflare: "Warm routes on Workers" (KV required, an origin for scheduled/queue, what another colo does after updateTag and a warm, entries under 60 s). skills/vercel and skills/deployment-caching: the regional Runtime Cache. skills/ppr: capturing a shell before the first visitor. skills/testing: warming under serveShellRequest. skills/rango: the task row; catalog.json regenerated. - docs/design/prerender-every-route.md: marked built. The analysis is kept; where the build differs the text says what was built, the seven decisions on the design's corrections are recorded, and "What changed between the design and the build" lists every difference (the request-entry context, the later-millisecond start, the status order, `fresh`, two more refusal values, the SSR loader leaf, the edge-only suite not built). - docs/design/ondemand-prerender.md: the result shape and the refresh diagram no longer show skipped-not-on-demand. - docs/internal/feature-map.md, feature-file-map.md, docs/README.md: the new capability, files and exports. - CHANGELOG: "Added: router.prerender() warms any route before traffic". The refresh pattern is documented as the two-colo test shows it: after updateTag and a warm no location serves the old content, a location with no copy of its own reads the warmed entry, and on CFCacheStore a colo still holding the invalidated copy renders once for itself unless the store uses tagPurge. * refactor(router): cleanup pass on router.prerender() for every route Bound the later-millisecond wait, drain the warm response without keeping it, keep ordinary requests free of warm-only work, and settle one notion of dev, one result composer and one way to record each fact on the warm record. Pins the forced capture's ordering with a unit test and documents that a warm also overwrites a route's or loader's own cache({ store }).
2 tasks done
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 #1057
Problem
On a route that combines
Prerenderwithppr, a settled handle value pushed by anssr: falseloader was in the shell's HTML but not in the handle data the browser hydrated with. Every shell HIT failed hydration (React error #418 in production, "Hydration failed because the server rendered HTML didn't match the client" in dev), React removed the element, and nothing delivered it afterwards. The document MISS, and the same route withoutPrerender, were clean, and the loader's data was correct: only its handle values were lost. Cause: the prerender store supplies such a route's handler layer, so its shell capture writes no doc record (settleCaptureRecordreturnsprerender), and the doc record is where a plainpprshell keeps the loader-owned pushes its prelude rendered (handles+handleOwners). The build-time prerender entry the HIT restores holds no loader push (loaders never run at build), and a promise-freessr: falseloader is served from its pin on the HIT without running. So the values were in the HTML and stored nowhere.On the Cloudflare preset the same loss happened in development for a second reason: the on-demand build-time shell (
/__rsc_shell, run in the temp Node realm) found no prerender store there, rendered thePrerenderhandler live into a doc record, and the worker's HIT, which serves the handler layer from the prerender store, never read it.Before / after
Nothing changes in app code.
End-to-end consumer example
vite buildbakes/articles/introand/articles/routingand their shells. The first request after a deploy isx-rango-shell: HIT: the HTML shows the updated label, the page hydrates with it, and the live readers count arrives after hydration. A URL with a query string (/articles/intro?ref=x) is captured at runtime on its first request and behaves the same from its second.Semantics
Prerender+pprroute,loader(L, { ssr: false }):Lpromise-free, pushes a settled value (shell baked byvite build, or captured at runtime)handlesrecord)Lalso pushes a promiseppr.maxSnapshotBytesLruns and its value replaces it after hydration, as on a plainpprshellhandlesrecord)version)pprroute withoutPrerenderCode change
Non-test source, against
origin/main, after the review pass below: the fix in 9 files (cache/handle-snapshot.ts,cache/shell-snapshot.ts,cache/types.ts,cache/cache-scope.ts,server/handle-store.ts,server/request-context.ts,router/match-middleware/cache-lookup.ts,rsc/shell-capture.ts,rsc/rsc-rendering.ts) plus 8 lines invite/router-discovery.ts, and about 100 lines for theserveShellRequestextension (testing/serve-shell-request.tsand an internal seam incache-lookup.ts).Storage: a
handlessnapshot record.captureAndStoreShell(rsc/shell-capture.ts) writes it for a capture without a doc record (the prerender-served one): the handle arrays that hold a loader-owned push, each whole, in the doc record's format (ShellSnapshotHandlesValue = Pick<CachedEntryData, "handles" | "handleOwners">, built by the newcaptureOwnedHandlesincache/handle-snapshot.tsoverHandleStore.getOwnedSegmentIds()). It is written even when empty (so the HIT always applies the pins' authority, as a doc-record hit does), and it is exempt fromppr.maxSnapshotByteslike the doc record it stands in for.Restore.
serveShellHitpasses the record on the HIT tail's marker (_shellImplicitCache.prerenderHandles, read byshellPrerenderHandlesincache/shell-snapshot.ts).yieldFromStore(router/match-middleware/cache-lookup.ts) restores it right after the prerender store's own handles, with the doc record's rule, before the render barrier freezes the document snapshot (#1054):restoreRecordHandles(cache/handle-snapshot.ts) is the doc record's own restore, now shared:CacheScope.lookupRouteDetailedcalls the same helper (authority from the pins with or without a blob, decode, the boundary filter,restoreHandles). So a pinned loader's copy stands (pushRestored), a loader without a pin gets a placeholder its run replaces, and a pinned loader's settled push on the HIT is dropped, exactly as for a doc record.Cloudflare dev.
router-discovery.tssetsglobalThis.__PRERENDER_DEV_URLin its Cloudflare branch, where it already recordsdevServerOrigin, so the temp Node realm that runs/__rsc_shellgets the dev prerender store the Node preset already has in-process. Probed before the change: the capture's lookup sawpr=true devUrl=undefined manifest=undefined, and the endpoint returned an entry with a doc record (docKey: "doc:build.invalid/...") for both routes probed,/ppr-push/pre-settledand the existing/ppr-shell/prerendered/alpha.Why this storage.
hasDocRecordtrue: the HIT would start choosing the doc record over the prerender store and navigation replay eligibility would change. Ruled out.ShellCacheEntryfield is not additive across stores:CFCacheStore's frame head andVercelCacheStore's envelope encode the entry field by field. The snapshot travels as a JSON array in every store, the build-shell manifest and the dev endpoint.handleOwnersis index-aligned), andmaxSnapshotBytesdrops pins, which would bring the hydration error back.Stored format: additive. One new snapshot family. Every existing reader filters by family (
buildShellLoaderSeedreadsloader,SeededShellStoreandhasDocRecordreadsegment), so an older router reading a new entry (rollback) ignores the record, and a new router reading an entry without it restores nothing more: it serves exactly as 0.21 did. No version bump.serveShellRequestdrivesPrerenderroutes. It used to render aPrerenderroute's handler live, so aPrerender+pprroute captured a doc record like a plainpprroute and no unit test could reach the prerender-served shell. It now bakes the URL's artifact withrouter.matchForPrerender(as the dev/__rsc_prerenderendpoint does,devModeso aPassthroughroute keepsgetParams()), and serves it through the production prerender store (createPrerenderStoreoverglobalThis.__loadPrerenderManifestModule, installed for the call asshell-prerender-phase.tsinstalls it for build-time shells).resetShellTestState()drops baked artifacts. Build-time shells stay e2e-only.Test plan
Red on
origin/main(fix sources reverted, test files and theserveShellRequestextension kept), green now.src/testing/__tests__/serve-shell-request-prerender.rsc-test.tsx(serveShellRequest,readHandles()), 5 tests:ssr: falseloader's settled push is in the hydration data, as in the shell. Red:expected [ 'handler-note' ] to deeply equal [ 'handler-note', 'settled-only@g1' ](:165). Also pins: no doc record (docKeyundefined), the handler ran once (the bake), the loader data is its pin.expected [] to deeply equal [ 'settled-note@g1' ](:173).maxSnapshotBytes) hydrates with the capture's push, and the run's push takes its place after hydration. Red:expected [ 'handler-note' ] to deeply equal [ 'handler-note', 'settled-only@g1' ](:186).pprroute keeps the same push in its doc record (control, green before and after).origin/main):cache/__tests__/handle-snapshot-owned-pushes.test.ts,captureOwnedHandles(3):TypeError: captureOwnedHandles is not a function.cache/__tests__/shell-snapshot.test.ts, the handles record (2):TypeError: shellPrerenderHandles is not a function; the second (not a doc record, not a pin, not served by the doc scope) is a control.rsc/__tests__/shell-capture.test.ts, a prerender-served capture's handles record (3):expected [ 'loader M0R0D0.Bake' ] to deeply equal [ 'loader M0R0D0.Bake', …(1) ];expected [] to deeply equal [ 'handles' ](overmaxSnapshotBytes);expected undefined to deeply equal { handles: {} }.router/match-middleware/__tests__/cache-lookup-prerender-shell-handles.test.ts(5): the three restore cases red withexpected { restored: [ 'handler' ], … } to deeply equal { restored: [ 'handler', … ], … }; an entry without the record and a request that is not a shell HIT tail are controls.tests/shared-e2e/src/index.ts(expectShellHitKeepsSettledLoaderPush,expectBuildShellHitKeepsSettledLoaderPush,expectPrerenderShellHitDeliversPromisePush), dev and(production)describes in both apps (packages/rangojs-router/e2e/shell-push-ownership.test.ts,tests/cloudflare-basic/e2e/ppr-push-ownership.test.ts), new routes/shell-push/{pre-settled,ppr-settled,pre-deferred}and/ppr-push/{pre-settled,ppr-settled,pre-deferred}:?probe=): MISS clean, HIT HTML has the row, no hydration error, the row never changes from first parse through hydration. Red in all 4 (app x mode):push-noterows[], expected["settled-only@g1"](expectHitKeepsRows,index.ts:2384on this branch;:2354at the red run, before the fix(router): shell capture no longer waits for loader pushes its record leaves out (#1035) #1059 merge), with "Hydration failed" (dev) / React Fix prefetched parallel navigation streaming #418 (production).Prerender+ppr: red in all 4: rows["settled-note@g2", "deferred-note@g2"], expected["settled-note@g1", "deferred-note@g2"](expectPrerenderShellHitDeliversPromisePush,index.ts:2469;:2441at the red run), plus the hydration error.pprwithoutPrerender): green before and after in all 4.pnpm run typecheck,pnpm run test:unit:all,pnpm run lint,pnpm run format,pnpm check:e2e-bucketing,pnpm check:e2e-parity --strict,pnpm run check:docs-api,pnpm check:bundle-guards.--no-deps, dev + production: router test-app files matchingshell ppr prerender semantic-matrix: 417 passed (semantic matrix 53 of them); cloudflare-basic files matchingppr prerender: 305 passed, 1 skipped (the existingtest.fixmeinprerender-navigation.test.ts).ppr-shell.test.ts"updateTag evicts the build-time shell; the runtime capture then owns the route (production)":Expected: "HIT",Received: "MISS"(ppr-shell.test.ts:2382). It passed alone and as a dev + production pair on parallel workers, and failed deterministically with--workers=1(dev first), on this branch and onorigin/main's sources (5b7d02aa) alike. Cause: the dev and preview servers shared.wrangler/state, so the dev twin'supdateTag("ppr-pp-evict-shell")marker, written after the build, evicted the production build-time shell. Fixed in its own commit (see Notes); the--workers=1pair passes after it.router-YzEas1-z.js142,744 B raw / 45,698 B gzip, same content hash with and without the fix (test-app production build).Notes
Shell kinds covered. A
Prerender+pprroute is served from (1) a shell captured at runtime (producer A: a URL with a query string, an SWR recapture afterppr.ttl), (2) the shellvite buildbakes (producer B,shell-prerender-phase.ts->captureShellForBuild), and in dev (3) the same producer B on demand through/__rsc_shell. All three go throughsettleCaptureRecord+captureAndStoreShellto write andserveShellHit->yieldFromStoreto read, so one write site and one read site cover them; (3) on Cloudflare also needed the capture realm fix above.The Cloudflare dev capture change is wider than handles. Before, the dev build-time shell of a
Prerender+pprroute on Cloudflare was rendered from thePrerenderhandler run live in the temp realm; now it replays the prerendered output, as production's build phase does (which skips any capture that did not replay the store). An app whosePrerenderhandler behaves differently at build than live would see its dev shell change to match production.Old entries. A runtime shell stored by 0.21 has no
handlesrecord and keeps its hydration error until replaced; the upgrade changes the router's document version, so the deploy that ships it retires those entries unless the app pinscreateRouter({ version }). Build-baked shells are rebuilt by every build.serveShellRequestbehavior change. A consumer test that served aPrerenderroute throughserveShellRequestnow gets the production serve path (baked artifact, handler not run per request) instead of a live handler render. No test in this repo did.Merged
origin/maintwice while working (fix(router): superseded push no longer follows a stale redirect; client redirect loops stop at 20 hops #1058, fix(router): shell capture no longer waits for loader pushes its record leaves out (#1035) #1059); the conflicts were adjacent fixture additions and changelog entries. fix(router): shell capture no longer waits for loader pushes its record leaves out (#1035) #1059'ssettleCaptureRecordchange and this one touch different functions inshell-capture.ts.cloudflare-basic e2e isolation (separate commit, test infra only).
tests/cloudflare-basic/vite.config.tsgivesvite previewits own Miniflare persistence (persistState: { path: ".wrangler/state-preview" }whenisPreview), so a dev test's KV writes (tag markers above all) can no longer reach the production server in a local run that starts both. CI runs each project in its own job and never shared the state, so CI behavior is unchanged. The failure it fixes reproduces onorigin/main; it is unrelated to Prerender + ppr: a settled push from an ssr: false loader is in the shell HTML but never reaches the client (hydration error on every HIT) #1057.Independent review (reuse, simplification, efficiency, depth; two agents, two angles each) after the fix. Applied in
c1e3a611: one sharedrestoreRecordHandlesfor the doc record and this record (both reviewers flagged the duplicated authority-then-restore sequence);HandleStore.getOwnedSegmentIds()replaces a fullgetData()walk at capture (the store is already sealed there bysettleCaptureRecord,shell-capture.ts:1651, so nothing about sealing changes); a simpler artifact cache inserveShellRequest; repeated comments cut to the invariants. Not applied, with reasons: writing a doc record instead (changes which layer serves the HIT); skipping the record when nothing is owned (the HIT must still apply the pins' authority); counting the record againstmaxSnapshotBytes(the HIT needs it for its HTML and hydration data to match, like the doc record); building it next to its push site (it is built before the render-error check so an encode error refuses the capture); sharing the manifest stub withshell-prerender-phase.ts(keeps plugin code untouched); a request-level store override in place ofsetPrerenderStoreForTests(serveShellRequestis sequential by contract). A reviewer raised a possible loop: with__PRERENDER_DEV_URLset in the Vite process, could the dev/__rsc_shellcapture fetch a dev shell from itself? It cannot:fetchDevShellEntryis reached only from the document serve path (lookupBuildShell,rsc-rendering.ts), and the endpoint callscaptureShellForBuilddirectly (router-discovery.ts); the Node preset sets the same global in the same process.Second run by the reviewer on the final commit: gate green (router 7588 unit, 521 RSC with 2 expected failures); a dev test that flaked once during the review run (
shell-cache.test.ts"a request-partitioned route ...") passed 5 of 5 with--repeat-each=5.