Skip to content

feat(router): router.prerender() warms any route before traffic - #1063

Merged
ivogt merged 29 commits into
mainfrom
feat/prerender-every-route
Oct 5, 2026
Merged

ivogt merged 29 commits into
mainfrom
feat/prerender-every-route

Conversation

@ivogt

@ivogt ivogt commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

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: a Prerender(..., { onDemand }) route. Every other cache the router has fills only from a visitor's request: a ppr route's shell, a route's cache() records, the "use cache" results the render reaches, a loader's own cache(), the document cache. A deploy that changes a router's server code moves its cache versions, and an updateTag() 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). A clientUrls() group cannot use Prerender() at all, so a group route with ppr had no way to be ready before its first visitor. And a call for a plain route answered skipped-not-on-demand and did nothing.

Before vs. after

Before (#640 alone): only on-demand routes.

const prerender = router.prerender({ env, ctx });

await prerender("/guides/intro"); // Prerender(..., { onDemand }): rendered
await prerender("/products/1"); // any other route:
// { ok: false, status: "skipped-not-on-demand", routeName: "product" }
// the next visitor of /products/1: x-rango-shell: MISS

After: one verb, dispatched by route.

const prerender = router.prerender({ env, ctx, origin: "https://shop.example" });

await prerender("/guides/intro"); // on-demand: rendered, then its caches warmed
await prerender("/products/1"); // any other route:
// { ok: true, path: "warm", status: "warmed",
//   target: "https://shop.example/products/1", responseStatus: 200,
//   caches: { writes: { record: 1, item: 2, response: 0, shell: 1 }, shell: "stored" } }
// the next visitor of /products/1: x-rango-shell: HIT

A warm is a cookie-free GET of 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.

// router.tsx
export const router = createRouter<Env>({
  urls: urlpatterns,
  cache: (env, ctx) => ({
    // KV makes the store shared by every colo (scope "global"); without it a
    // warm is refused with skipped-store-not-shared.
    store: new CFCacheStore({ ctx: ctx!, kv: env.CACHE_KV, defaults: { ttl: 300, swr: 3600 } }),
  }),
});

// urls.tsx: a ppr product page tagged per product, and the webhook the CMS calls.
cache({ ttl: 300, tags: (ctx) => [`product:${ctx.params.id}`] }, () => [
  path("/products/:id", ProductPage, { name: "product", ppr: true }),
]),
path.json("/hooks/product-changed", async (ctx) => {
  const { id } = (await ctx.request.json()) as { id: string };
  // No location serves the old copy any more...
  await updateTag(`product:${id}`);
  // ...and the new one is written before the next visitor. No origin needed:
  // the warm takes this request's.
  const result = await router.prerender({ env: ctx.env, ctx: ctx.executionContext })(
    `/products/${id}`,
  );
  return { status: result.status, caches: result.caches };
}),

// worker entry: after a deploy, top up what is cold. A cron has no request,
// so it names the origin visitors use (cache keys carry the host).
export default {
  fetch: (request: Request, env: Env, ctx: ExecutionContext) => router.fetch(request, { env, ctx }),
  async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    const prerender = router.prerender({ env, ctx, origin: "https://shop.example" });
    const ids = await env.CMS.listProductIds();
    ctx.waitUntil(
      prerender.many(
        ids.map((id) => `/products/${id}`),
        { concurrency: 4, onlyIfStale: true },
      ),
    );
  },
};

Semantics

What one call does, by route and store:

Route App cache store shared ("global", "regional") Not shared ("local", undeclared) No createRouter({ cache })
Prerender(..., { onDemand }) #640's requestless render into the prerender store, then one warm request on top of the new entry. rendered with caches #640 only. rendered without caches #640 only
any other route warm request. warmed skipped-store-not-shared, nothing renders (dev warns once) no-store
no route matches no-match same same

Which stores are shared (SegmentCacheStore.scope, new and optional):

Store scope Warm
CFCacheStore with kv "global" yes
CFCacheStore without kv "local" refused
VercelCacheStore "regional" yes; fills the region the call runs in (documented, not detected)
MemorySegmentCacheStore "local" refused in production; allowed under the Vite dev server
custom store its own only when it declares "global" or "regional"

The origin of a warm, first match wins:

Target Origin
full URL string or URL its own
path or { route, params }, origin on the binding the binding's
path or { route, params }, called from a request (action, route handler, onRevalidate) that request's
path or { route, params }, called from a cron or queue with no origin skipped-no-origin
on-demand target none needed; without one only the follow-up warm is skipped

Statuses of the warm path (ok: false unless noted):

Status When
warmed (ok: true) the request rendered and at least one cache wrote
already-fresh (ok: true) onlyIfStale: nothing was cold or stale
skipped-store-not-shared the app store is local or declares nothing
skipped-no-origin a path target with no origin
no-store no cache store, or enabled: false
skipped-personalized an identity guard refused a read (cookies(), headers(), the theme) inside cache(), "use cache" or the shell capture
shell-not-stored a ppr route whose shell was not stored; caches.shell / caches.refusal say why
skipped-uncached the request rendered and no cache wrote
render-failed the handler threw, the response was not 200 (responseStatus, e.g. 302 from auth middleware), or the render reported an error
skipped-unsupported-target a hash, or a reserved parameter (_rsc*, __no_cache)

Guards, for a warm versus a visitor's request:

Guard Visitor Warm
identity reads in a shared scope refused refused, reported
a write that started before a tag invalidation not stored not stored
render errors, maxSnapshotBytes, store size limits decide decide
cache(false), condition(), document private / no-store / Set-Cookie decide decide
capture queue capacity and wait budget decide decide, reported
capture in-flight dedup, refused-capture backoff, skip-stored apply bypassed
"use cache" joining an in-flight execution joins leads its own
capture write barrier 1.5 s up to 10 s by default

Deliberately unchanged:

  • Every request that is not a warm. The mode is one field on the request context that only the runner's own Request object can set (a WeakMap, never a header, parameter or cookie).
  • The stores. A warm writes through the existing write paths; scope is the only contract addition.
  • On-demand targets still reject search params (their key is route + params). The check moved after the route match so a warm target keeps them.
  • CFCacheStore's cross-colo behavior: another colo keeps its own Cache API copy until it expires, and an entry whose ttl + swr is 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-guards reports the router-*.js chunk of tests/cloudflare-basic at 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 with gzip -c).

  • One predicate, read where each layer already reads the request context. src/prerender/warm-request.ts:

    export function isWarmReplace(ctx: WarmContext | null | undefined): boolean {
      return (
        ctx?._prerenderWarm?.mode === "replace" && ctx._shellCaptureRun !== true
      );
    }

    Read in CacheScope.lookupRouteDetailed (after the enabled / condition() checks), executeLoaderData's getItem, 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): scheduleShellCapture skips the in-flight dedup and the backoff, attemptCapture waits longer on the write barrier. The capture itself reads normally (_shellCaptureRun is excluded from the predicate), so it replays what the warm just wrote instead of rendering every handler twice. ShellCaptureDebugEvent gains refusal.

  • The store declaration. CacheStoreScope and SegmentCacheStore.scope (cache/types.ts), set by the three shipped stores; cache/store-scope.ts is the gate, including the dev-server rule for the memory store.

  • The dispatch. prerender/create-prerender-trigger.ts matches first, then branches: the on-demand body is feat(router): on-demand (ISR-style) prerender #640's, moved into renderOnDemand unchanged apart from path on its results; warm() resolves the origin and the store and calls prerender/warm.ts (runWarmRequest: the request, the drain, the wait; composeWarmResult: the status and the finished PrerenderResult). The collecting ExecutionContext and the bounded later-millisecond wait live in cache/background-task.ts, shared with serveShellRequest. The unsupported-target rejection (a hash or a reserved param) happens once in run, before the dispatch. The trigger stays free of RSC imports: router.ts injects fetch, resolveCacheConfig and ambientOrigin.

  • The handler (rsc/handler.ts) reads the mark off the incoming Request, 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 module router.fetch's handler falls back to in tests. The handler reads it behind import.meta.env.MODE !== "production", which a production build folds away (the built cloudflare-basic handler is () => import("../ssr/index.js")); NODE_ENV cannot be the gate because the rsc Vitest project forces it to production. testing/serve-shell-request.ts installs 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:

  1. A warm leaves the caller's async context. Called from a route handler, the warm request inherited that request's AsyncLocalStorage scopes. router/manifest.ts loadManifest then 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, one AsyncLocalStorage.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.
  2. 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, so 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 8
  • pnpm run lint, pnpm run format
  • pnpm check:e2e-bucketing, pnpm check:e2e-parity --strict, pnpm run check:docs-api, pnpm check:skills, pnpm check:bundle-guards
  • e2e with --no-deps, packages/rangojs-router, review pass: the 17 prerender|on-demand|ondemand files (no hmr) plus shell-cache*.test.ts and semantic-matrix.test.ts: dev 182 passed, production 209 passed
  • e2e with --no-deps, tests/cloudflare-basic, review pass: the 10 prerender* files (no hmr): dev 42 passed, 1 skipped (prerender-navigation.test.ts:142, skipped in the file), production 113 passed

Userland, through router.prerender() and serveShellRequest (@rangojs/router/testing/flight):

  • src/testing/__tests__/prerender-warm.rsc-test.tsx (10, written red with the design, unchanged): a warmed ppr route is the next visitor's HIT; a warm replaces a shell in place while the old one keeps serving; a warm fills and replaces a cache() record; a local and an undeclared store are refused with no render; a cookie reader is skipped-personalized; .many() mixes paths; an on-demand route renders and then warms its loader's own cache().
  • src/testing/__tests__/prerender-warm-layers.rsc-test.tsx (22): the same replace contract for a loader's cache(), "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; updateTag then prerender; onlyIfStale; each refusal.

Unit:

  • src/cache/__tests__/store-scope.test.ts (5, written red) and warm-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.
  • Per layer, the forced miss and the guards that still decide: 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: updateTag then the warm's write, across two CFCacheStore instances 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 in tests/shared-e2e/src/index.ts):

  • 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's 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)

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 failed

The 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); the updateTag then prerender test failed one run in three before the later-millisecond start (shell: "refused", 0 of 12 after).

Notes

  • updateTag then prerender is 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 under tagPurge. A colo that still holds its own (now invalidated) Cache API copy answers a miss and renders once for itself: CFCacheStore.get returns null for 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.
  • PrerenderResult changed shape. It is discriminated by path; key and tags exist on the on-demand variant only, so code that read result.key after result.ok now narrows on result.path === "on-demand". skipped-not-on-demand is removed, not deprecated: feat(router): on-demand (ISR-style) prerender #640 is unreleased.
  • An on-demand refresh now also sends one warm request when the app store is shared and an origin resolves, including from feat(router): on-demand (ISR-style) prerender #640's own e2e triggers. Its status is unchanged; the result gains caches.
  • Every request captures its entry async context once (see above). That is the only cost a request that is not a warm pays, besides reading one field at each cache read.
  • A warm only runs router.fetch. An app entry that builds its own createRSCHandler({ cache, version }) is not seen; put those options on createRouter.
  • Only document records are replaced. Navigation (partial:) and intercept records fill on the first navigation. A ppr route's navigations replay the document shell, so they are covered.
  • The memory store is shared under the dev server only. It follows the dev server (__PRERENDER_DEV_URL), not NODE_ENV. Production reads the class's own scope declaration ("local"); that declaration is the one line to change if the answer is revisited.
  • Middleware, onError and telemetry see warm requests: an anonymous GET with no user agent and no IP header. There is no public way to tell a warm from a visitor in this version.
  • A custom store with an unrelated scope member stops typechecking against SegmentCacheStore, and is refused at run time.
  • Not built: the edge-only (CFCacheStore without 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.
  • Follow-ups worth an issue: KV fall-through on an invalidated L1 hit (would make the refresh pattern spare the render in every colo); a second synthetic partial request to fill partial: records; a read-only isPrerenderWarm() for middleware.

Review pass

Applied:

  • The later-millisecond wait is bounded (ten turns, awaitLaterMillisecond in cache/background-task.ts), shared by the warm and serveShellRequest; unit-tested with a frozen clock.
  • The warm drains its response without retaining it and cancels the body of a non-200.
  • No extra work on an ordinary request: the loader getItem is the store's original closure unless the request is a warm; lookupRouteDetailed reuses the context it already read for the fragment-payload check; the SSR test seam is behind import.meta.env.MODE !== "production" and is gone from a production build. The per-request AsyncLocalStorage.snapshot() stays.
  • One notion of dev on the warm path: isViteDevServer() (exported from cache/store-scope.ts) now drives router.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); isDev stays for the producer context and the markStale warnings.
  • composeWarmResult in warm.ts builds the finished PrerenderResult and passes outcome.responseStatus through; the status type is derived from PrerenderResult.
  • An unsupported target (hash or reserved param) is rejected once in run, with the path of the route kind; the on-demand search-param rejection stays.
  • One way to record each fact on the warm record: the record owns renderErrors (the handler adopts it as the request's _renderErrors, no back-reference assigned afterward); a document-cache write counts through noteWarmWrite("response") and noteWarmDocument records the outcome only; the identity note is recorded where the refusal throws (guardIdentityRead, refuseInCacheScope); the helpers take the typed WarmContext.
  • createCollectingExecutionContext moved to cache/background-task.ts and serveShellRequest uses it.
  • A shell-capture unit test pins that a visitor's capture finishing first does not release the warm's guard, so nothing is admitted to write after the warm's.
  • Docs: the forced miss is a flag, not a wrapper (comment and design doc); the skill and design doc say a warm also overwrites a route's or loader's own cache({ store }) and that the shared-store check covers the app store only; onRevalidate receives (target, env, ctx) (skill, ondemand-prerender.md); the history-narrating comment on captureRequestEntryContext is cut to the invariant.

Not applied (decided):

  • A store wrapper in place of the isWarmReplace reads, or a readUnlessWarm helper: a wrapper on the app store misses a route's or loader's own cache({ store }) (CacheScope.getStore bypasses the app store) and the "use cache" in-flight dedup, which lives outside any store.
  • Gating the per-request snapshot: it has to be taken at request entry, and taking it on first use would make the first nested warm behave differently.
  • A public loadSSRModule option on createRouter: a public option for a test need.

Run by the reviewer on the final commit (3b620fb7, after pnpm 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-deps over every prerender|on-demand|ondemand|shell|ppr|cache|semantic-matrix file (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, main is merged into this branch and it is retargeted.

ivogt added 28 commits July 3, 2026 00:21
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 }).
Base automatically changed from feat/ondemand-prerender to main October 5, 2026 13:45
@pkg-pr-new

pkg-pr-new Bot commented Oct 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 21a7980

@ivogt
ivogt merged commit 844ccda into main Oct 5, 2026
43 checks passed
@ivogt
ivogt deleted the feat/prerender-every-route branch October 5, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

router.prerender() for every route: warm runtime caches before traffic, shared stores only (design)

1 participant