Repository navigation
feat(router): router.prerender() warms any route before traffic - #1063
Merged
Merged
Conversation
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.
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
- 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
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.
…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.
…he 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.
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.
…oses 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).
…re 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).
- 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
…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.
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.
The documented one-liner binds the runner with it:
onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target).
# Conflicts: # packages/rangojs-router/src/testing/serve-shell-request.ts
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.
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.
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.
…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.
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).
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.
…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"}.
- 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.
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 }).
commit: |
This was referenced Oct 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.
Base:
feat/ondemand-prerender(#640). Lands together with #640, in the same release. Closes #1062.Problem
After #640,
router.prerender()can make one kind of route ready before a visitor asks for it: aPrerender(..., { onDemand })route. Every other cache the router has fills only from a visitor's request: apprroute's shell, a route'scache()records, the"use cache"results the render reaches, a loader's owncache(), the document cache. A deploy that changes a router's server code moves its cache versions, and anupdateTag()drops the entries it names, so in both cases the first visitor to each URL gets a cold render (x-rango-shell: MISS, every handler runs). AclientUrls()group cannot usePrerender()at all, so a group route withpprhad no way to be ready before its first visitor. And a call for a plain route answeredskipped-not-on-demandand did nothing.Before vs. after
Before (#640 alone): only on-demand routes.
After: one verb, dispatched by route.
A warm is a cookie-free
GETof the URL a visitor requests, sent in process through the router's own handler, in a mode where every runtime cache read misses and every write replaces the entry under the visitor's key. Nothing is evicted first: the old entries serve until the new ones are written.End-to-end example
A Cloudflare app that warms its catalog after a deploy and refreshes one product when the CMS changes it.
Semantics
What one call does, by route and store:
"global","regional")"local", undeclared)createRouter({ cache })Prerender(..., { onDemand })renderedwithcachesrenderedwithoutcacheswarmedskipped-store-not-shared, nothing renders (dev warns once)no-storeno-matchWhich stores are shared (
SegmentCacheStore.scope, new and optional):scopeCFCacheStorewithkv"global"CFCacheStorewithoutkv"local"VercelCacheStore"regional"MemorySegmentCacheStore"local""global"or"regional"The origin of a warm, first match wins:
URL{ route, params },originon the binding{ route, params }, called from a request (action, route handler,onRevalidate){ route, params }, called from a cron or queue with nooriginskipped-no-originStatuses of the warm path (
ok: falseunless noted):warmed(ok: true)already-fresh(ok: true)onlyIfStale: nothing was cold or staleskipped-store-not-sharedskipped-no-originno-storeenabled: falseskipped-personalizedcookies(),headers(), the theme) insidecache(),"use cache"or the shell captureshell-not-storedpprroute whose shell was not stored;caches.shell/caches.refusalsay whyskipped-uncachedrender-failedresponseStatus, e.g. 302 from auth middleware), or the render reported an errorskipped-unsupported-target_rsc*,__no_cache)Guards, for a warm versus a visitor's request:
maxSnapshotBytes, store size limitscache(false),condition(), documentprivate/no-store/Set-Cookieskip-stored"use cache"joining an in-flight executionDeliberately unchanged:
Requestobject can set (aWeakMap, never a header, parameter or cookie).scopeis the only contract addition.CFCacheStore's cross-colo behavior: another colo keeps its own Cache API copy until it expires, and an entry whosettl + swris under 60 s never reaches KV.Code change
Non-test source: 1,104 lines added, 75 removed (676 of the added lines are code, 372 JSDoc and comments, 56 blank). The design estimated about 670; the difference is the two things the build found (below), the result type and its documentation, and the capture's refusal reasons. The client bundle is unchanged:
pnpm check:bundle-guardsreports therouter-*.jschunk oftests/cloudflare-basicat 47,488 B gzip (ratchet 48,128 B), and the same chunk built from #640's source is 1 B larger (47,537 B against 47,536 B withgzip -c).One predicate, read where each layer already reads the request context.
src/prerender/warm-request.ts:Read in
CacheScope.lookupRouteDetailed(after theenabled/condition()checks),executeLoaderData'sgetItem,registerCachedFunction(the lookup and the follower loop),shellServePlan,createDocumentCacheMiddleware,serveResponseRouteWithCache. Each takes its existing miss branch; no store contract changes. Threading a flag through the call chains would have touched every signature between the handler and the stores; the request context is already what each of them reads.The capture.
ShellCaptureDescriptor.force(rsc/shell-capture.ts):scheduleShellCaptureskips the in-flight dedup and the backoff,attemptCapturewaits longer on the write barrier. The capture itself reads normally (_shellCaptureRunis excluded from the predicate), so it replays what the warm just wrote instead of rendering every handler twice.ShellCaptureDebugEventgainsrefusal.The store declaration.
CacheStoreScopeandSegmentCacheStore.scope(cache/types.ts), set by the three shipped stores;cache/store-scope.tsis the gate, including the dev-server rule for the memory store.The dispatch.
prerender/create-prerender-trigger.tsmatches first, then branches: the on-demand body is feat(router): on-demand (ISR-style) prerender #640's, moved intorenderOnDemandunchanged apart frompathon its results;warm()resolves the origin and the store and callsprerender/warm.ts(runWarmRequest: the request, the drain, the wait;composeWarmResult: the status and the finishedPrerenderResult). The collectingExecutionContextand the bounded later-millisecond wait live incache/background-task.ts, shared withserveShellRequest. The unsupported-target rejection (a hash or a reserved param) happens once inrun, before the dispatch. The trigger stays free of RSC imports:router.tsinjectsfetch,resolveCacheConfigandambientOrigin.The handler (
rsc/handler.ts) reads the mark off the incomingRequest, attaches the record, and uses the cache config the trigger gated instead of resolving it again, so the store that was checked is the store that is written.Testing primitive.
rsc/ssr-module-loader.ts: the SSR modulerouter.fetch's handler falls back to in tests. The handler reads it behindimport.meta.env.MODE !== "production", which a production build folds away (the builtcloudflare-basichandler is() => import("../ssr/index.js"));NODE_ENVcannot be the gate because the rsc Vitest project forces it toproduction.testing/serve-shell-request.tsinstalls its existing stub there when it loads; everything else on a warm's path is production code.Two things the build found that the design did not have:
AsyncLocalStoragescopes.router/manifest.ts loadManifestthen built the target's manifest into the calling route's store, and the handler answered 404 for every route no visitor had requested yet. A request now captures the async context it entered the handler with (RequestContext._runAtRequestEntry, oneAsyncLocalStorage.snapshot()per request, 0.8 µs measured on Node 24) and the runner dispatches through it. It is per request because workerd refuses a snapshot outside the request that created it.await updateTag(tag)followed by a warm failed one run in three on a memory store. The wait is bounded to ten macrotask turns (awaitLaterMillisecond): on Cloudflare the clock can stand still between I/O events, and at the bound the warm proceeds and the result reports a refused shell.Test plan
pnpm run typecheck(16 tasks)pnpm run test:unit:all: router 484 files / 7,859 tests and 44 rsc files / 561 tests (2 expected fail); cloudflare-basic 69 + 17; mini 25 + 3; vite-rsc-demo 25 + 1; shared-e2e 8pnpm run lint,pnpm run formatpnpm check:e2e-bucketing,pnpm check:e2e-parity --strict,pnpm run check:docs-api,pnpm check:skills,pnpm check:bundle-guards--no-deps,packages/rangojs-router, review pass: the 17prerender|on-demand|ondemandfiles (nohmr) plusshell-cache*.test.tsandsemantic-matrix.test.ts: dev 182 passed, production 209 passed--no-deps,tests/cloudflare-basic, review pass: the 10prerender*files (nohmr): dev 42 passed, 1 skipped (prerender-navigation.test.ts:142, skipped in the file), production 113 passedUserland, through
router.prerender()andserveShellRequest(@rangojs/router/testing/flight):src/testing/__tests__/prerender-warm.rsc-test.tsx(10, written red with the design, unchanged): a warmedpprroute is the next visitor's HIT; a warm replaces a shell in place while the old one keeps serving; a warm fills and replaces acache()record; a local and an undeclared store are refused with no render; a cookie reader isskipped-personalized;.many()mixes paths; an on-demand route renders and then warms its loader's owncache().src/testing/__tests__/prerender-warm-layers.rsc-test.tsx(22): the same replace contract for a loader'scache(),"use cache", the document cache and a response route; the key (search params,cache.searchParams, the host); the origin; a warm called from a route handler for a route no visitor has requested;updateTagthenprerender;onlyIfStale; each refusal.Unit:
src/cache/__tests__/store-scope.test.ts(5, written red) andwarm-store-scope.test.ts: what each store declares, and what the gate makes of it.src/prerender/__tests__/create-prerender-trigger.test.ts,warm.test.ts,warm-request.test.ts: the dispatch table, origin order, the search-param move, statuses, the mark, the wait.cache-scope.test.ts,cache-runtime-inflight.test.ts,document-cache.test.ts,rsc/__tests__/response-cache-serve.test.ts,shell-capture.test.ts,shell-capture-queue-skip.test.ts,rsc-rendering-shell-ppr.test.ts,server/__tests__/cookie-store.test.ts,request-entry-context.test.ts.src/cache/cf/__tests__/cf-cache-store-warm-refresh.test.ts:updateTagthen the warm's write, across twoCFCacheStoreinstances that share one fake KV and each have their own fake Cache API.Browser e2e, dev and
(production)in both apps (packages/rangojs-router/e2e/prerender-warm.test.ts,tests/cloudflare-basic/e2e/prerender-warm.test.ts; shared bodies intests/shared-e2e/src/index.ts):pprroute makes the next document requestx-rango-shell: HIT, and it hydrates cleancache()record a visitor's request wrotecookies()reportsskipped-personalizedand stays a MISSrouter.prerender({ env })with nooriginwarms on the request's origin, an on-demand and a plain target in one batchonlyIfStaleleaves a warmed shell alone (already-fresh)Red before green, the six e2e on #640's source (test-app, dev):
1) warming a ppr route makes the next document request a shell HIT that hydrates clean Error: {"ok":false,"status":"skipped-unsupported-target","target":"/warm/shell?probe=warm-73f0d801"} 2) a warm replaces a stored shell: the next document shows the newer render Error: {"ok":false,"status":"skipped-unsupported-target","target":"/warm/shell?probe=replace-c63d6185"} 3) a warm replaces a route cache() record a visitor's request wrote Error: {"ok":false,"status":"skipped-unsupported-target","target":"/warm/cached?probe=record-f63ea03e"} 4) a route that reads cookies() reports skipped-personalized and stores nothing Error: {"ok":false,"status":"skipped-unsupported-target","target":"/warm/personal?probe=personal-05ee16fc"} 5) a route handler calling router.prerender({ env }) with no origin warms on the request's origin ... Error: [{"ok":true,"status":"rendered","target":"/on-demand/batch-50d0d667",...},{"ok":false,"status":"skipped-unsupported-target",...}] 6) onlyIfStale leaves a warmed shell alone and reports already-fresh Error: {"ok":false,"status":"skipped-unsupported-target","target":"/warm/shell?probe=fresh-0d385a42"} 6 failedThe 15 tests written with the design were red on
1efeebae(the design commit) and are green here without a change to a test body. The two nested-call userland tests fail when the request-entry context is bypassed (status: "render-failed", a 404 from the handler); theupdateTagthenprerendertest failed one run in three before the later-millisecond start (shell: "refused", 0 of 12 after).Notes
updateTagthenprerenderis narrower than first described. The decision said the invalidation drops every colo's copy and their next read finds the warmed entry in KV. The two-colo test shows that holds for a colo with no copy of its own, and for every colo undertagPurge. A colo that still holds its own (now invalidated) Cache API copy answers a miss and renders once for itself:CFCacheStore.getreturnsnullfor an invalidated L1 hit without reading KV (its comment calls the fall-through a deferred follow-up). No location serves the old content. The skills and the design doc say this; the store is not changed.PrerenderResultchanged shape. It is discriminated bypath;keyandtagsexist on the on-demand variant only, so code that readresult.keyafterresult.oknow narrows onresult.path === "on-demand".skipped-not-on-demandis removed, not deprecated: feat(router): on-demand (ISR-style) prerender #640 is unreleased.caches.router.fetch. An app entry that builds its owncreateRSCHandler({ cache, version })is not seen; put those options oncreateRouter.partial:) and intercept records fill on the first navigation. Apprroute's navigations replay the document shell, so they are covered.__PRERENDER_DEV_URL), notNODE_ENV. Production reads the class's ownscopedeclaration ("local"); that declaration is the one line to change if the answer is revisited.onErrorand telemetry see warm requests: an anonymousGETwith no user agent and no IP header. There is no public way to tell a warm from a visitor in this version.scopemember stops typechecking againstSegmentCacheStore, and is refused at run time.CFCacheStorewithout KV) browser suite the design planned for the refusal. The refusal renders nothing, so the trigger's unit tests and the userland test cover it.partial:records; a read-onlyisPrerenderWarm()for middleware.Review pass
Applied:
awaitLaterMillisecondincache/background-task.ts), shared by the warm andserveShellRequest; unit-tested with a frozen clock.getItemis the store's original closure unless the request is a warm;lookupRouteDetailedreuses the context it already read for the fragment-payload check; the SSR test seam is behindimport.meta.env.MODE !== "production"and is gone from a production build. The per-requestAsyncLocalStorage.snapshot()stays.isViteDevServer()(exported fromcache/store-scope.ts) now drivesrouter.ts, the memory-store rule and the not-shared warning, so the warning and the rule cannot disagree. The trigger takes it as a dep (isViteDevServer);isDevstays for the producer context and themarkStalewarnings.composeWarmResultinwarm.tsbuilds the finishedPrerenderResultand passesoutcome.responseStatusthrough; the status type is derived fromPrerenderResult.run, with thepathof the route kind; the on-demand search-param rejection stays.renderErrors(the handler adopts it as the request's_renderErrors, no back-reference assigned afterward); a document-cache write counts throughnoteWarmWrite("response")andnoteWarmDocumentrecords the outcome only; the identity note is recorded where the refusal throws (guardIdentityRead,refuseInCacheScope); the helpers take the typedWarmContext.createCollectingExecutionContextmoved tocache/background-task.tsandserveShellRequestuses it.cache({ store })and that the shared-store check covers the app store only;onRevalidatereceives(target, env, ctx)(skill,ondemand-prerender.md); the history-narrating comment oncaptureRequestEntryContextis cut to the invariant.Not applied (decided):
isWarmReplacereads, or areadUnlessWarmhelper: a wrapper on the app store misses a route's or loader's owncache({ store })(CacheScope.getStorebypasses the app store) and the"use cache"in-flight dedup, which lives outside any store.loadSSRModuleoption oncreateRouter: a public option for a test need.Run by the reviewer on the final commit (
3b620fb7, afterpnpm build-router): gate green (router 7,859 unit, 561 RSC with 2 expected failures; typecheck, lint, format, bucketing, parity --strict, docs-api 283 identifiers, bundle guards, skills); e2e--no-depsover everyprerender|on-demand|ondemand|shell|ppr|cache|semantic-matrixfile (HMR excluded): router app 363 passed in dev and 378 in production (45 files), cloudflare-basic 205 passed (2 existing skips) in dev and 281 in production (41 files), no failures and no retries.Lands together with #640: this PR is stacked on its branch. After #640 merges,
mainis merged into this branch and it is retargeted.