Skip to content

fix(router): a Prerender + ppr shell HIT hydrates with its loader pushes (#1057) - #1061

Merged
ivogt merged 6 commits into
mainfrom
fix/1057-prerender-ppr-loader-pushes
Oct 4, 2026
Merged

ivogt merged 6 commits into
mainfrom
fix/1057-prerender-ppr-loader-pushes

Conversation

@ivogt

@ivogt ivogt commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #1057

Problem

On a route that combines Prerender with ppr, a settled handle value pushed by an ssr: false loader 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 without Prerender, 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 (settleCaptureRecord returns prerender), and the doc record is where a plain ppr shell 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-free ssr: false loader 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 the Prerender handler live into a doc record, and the worker's HIT, which serves the handler layer from the prerender store, never read it.

Before / after

const Notes = createHandle<string>();

const Product = createLoader(async (ctx) => {
  ctx.use(Notes)("In stock"); // settled: shell material
  return { stock: await db.stock() };
});

path("/product", Prerender(async () => <ProductPage />), { ppr: true }, () => [
  loader(Product, { ssr: false }),
]);
// <ProductPage> renders useHandle(Notes)

// Before, every shell HIT:
//   HTML:      <li>In stock</li>
//   hydration: no "In stock"  -> React #418 / "Hydration failed", the <li> is removed, never returns
// After, every shell HIT (build-baked and runtime-captured shells):
//   HTML:      <li>In stock</li>
//   hydration: "In stock"     -> hydrates clean, the <li> stays

Nothing changes in app code.

End-to-end consumer example

// src/urls.tsx
import { Prerender, createHandle, createLoader, urls } from "@rangojs/router";
import { ArticleView } from "./ArticleView.js"; // "use client", renders useHandle(Badges)

export const Badges = createHandle<string>();

export const ArticleMeta = createLoader(async (ctx) => {
  const meta = await ctx.env.DB.articleMeta(ctx.params.slug);
  ctx.use(Badges)(meta.updatedLabel); // settled: in the shell's HTML and hydration data
  ctx.use(Badges)(ctx.env.DB.liveReaders(ctx.params.slug)); // a promise: live, after hydration
  return { title: meta.title };
});

export const ArticlePage = Prerender(
  async () => [{ slug: "intro" }, { slug: "routing" }],
  async (ctx) => <ArticleView slug={ctx.params.slug} />,
);

export const urlpatterns = urls(({ path, loader }) => [
  path(
    "/articles/:slug",
    ArticlePage,
    { name: "article", ppr: { ttl: 300, swr: 120 } },
    () => [loader(ArticleMeta, { ssr: false })],
  ),
]);

vite build bakes /articles/intro and /articles/routing and their shells. The first request after a deploy is x-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 + ppr route, loader(L, { ssr: false }):

Case Before After
Shell HIT, L promise-free, pushes a settled value (shell baked by vite build, or captured at runtime) HTML has it, hydration does not: #418 / "Hydration failed", element removed for good HTML and hydration have it (the shell entry's handles record)
Same, development, Cloudflare preset, shell from the dev on-demand build capture same failure (the capture rendered the handler live into a doc record the HIT never reads) same as production: the capture replays the prerender store
Shell HIT, L also pushes a promise settled value missing from hydration (#418); promise after hydration settled value in HTML and hydration; promise after hydration (#1054 rule, unchanged)
Shell HIT, pins dropped by ppr.maxSnapshotBytes settled value missing from hydration hydrates with the capture's value; L runs and its value replaces it after hydration, as on a plain ppr shell
Shell HIT of an entry stored by 0.21 (no handles record) as before as before (deliberately unchanged; retired by the deploy's new document version unless the app pins version)
Document MISS clean unchanged
ppr route without Prerender clean (doc record) unchanged
Client navigation to the route prerender store + loaders run; one handle stream unchanged (no shell replay for a prerender-served entry)
Which layer serves the handler output on a HIT prerender store unchanged

Code 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 in vite/router-discovery.ts, and about 100 lines for the serveShellRequest extension (testing/serve-shell-request.ts and an internal seam in cache-lookup.ts).

Storage: a handles snapshot 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 new captureOwnedHandles in cache/handle-snapshot.ts over HandleStore.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 from ppr.maxSnapshotBytes like the doc record it stands in for.

// rsc/shell-capture.ts, captureAndStoreShell
if (!capture.navigationOnly && !hasDocRecord(snapshot, docKey)) {
  const { handles, owners } = captureOwnedHandles(reqCtx._handleStore);
  prerenderHandles = {
    handles: await encodeHandles(handles, (error) => reqCtx._renderErrors?.push(error)),
    handleOwners: owners,
  };
}

Restore. serveShellHit passes the record on the HIT tail's marker (_shellImplicitCache.prerenderHandles, read by shellPrerenderHandles in cache/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):

const shellHandles = reqCtx?._shellImplicitCache?.prerenderHandles;
if (handleStore && shellHandles) {
  await _restoreRecordHandles!(handleStore, shellHandles, loaderPins(ctx.entries, reqCtx));
}

restoreRecordHandles (cache/handle-snapshot.ts) is the doc record's own restore, now shared: CacheScope.lookupRouteDetailed calls 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.ts sets globalThis.__PRERENDER_DEV_URL in its Cloudflare branch, where it already records devServerOrigin, so the temp Node realm that runs /__rsc_shell gets the dev prerender store the Node preset already has in-process. Probed before the change: the capture's lookup saw pr=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-settled and the existing /ppr-shell/prerendered/alpha.

Why this storage.

  • A doc record would make hasDocRecord true: the HIT would start choosing the doc record over the prerender store and navigation replay eligibility would change. Ruled out.
  • A new top-level ShellCacheEntry field is not additive across stores: CFCacheStore's frame head and VercelCacheStore'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.
  • Carrying the pushes on the loader pins: pins are per loader while pushes interleave per segment and handle (that is why handleOwners is index-aligned), and maxSnapshotBytes drops pins, which would bring the hydration error back.
  • The prerender entry itself is an immutable build artifact a runtime capture cannot write to.

Stored format: additive. One new snapshot family. Every existing reader filters by family (buildShellLoaderSeed reads loader, SeededShellStore and hasDocRecord read segment), 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.

serveShellRequest drives Prerender routes. It used to render a Prerender route's handler live, so a Prerender + ppr route captured a doc record like a plain ppr route and no unit test could reach the prerender-served shell. It now bakes the URL's artifact with router.matchForPrerender (as the dev /__rsc_prerender endpoint does, devMode so a Passthrough route keeps getParams()), and serves it through the production prerender store (createPrerenderStore over globalThis.__loadPrerenderManifestModule, installed for the call as shell-prerender-phase.ts installs 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 the serveShellRequest extension kept), green now.

  • Userland, src/testing/__tests__/serve-shell-request-prerender.rsc-test.tsx (serveShellRequest, readHandles()), 5 tests:
    • a promise-free ssr: false loader'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 (docKey undefined), the handler ran once (the bake), the loader data is its pin.
    • a promise push is not in the shell and arrives after hydration, next to the settled push the shell kept (fix(router): a document served from a PPR shell hydrates with the handle data its shell was rendered from (#1035) #1054). Red: expected [] to deeply equal [ 'settled-note@g1' ] (:173).
    • an entry without loader pins (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).
    • a plain ppr route keeps the same push in its doc record (control, green before and after).
    • an entry stored before the record existed (0.21) still serves, as it did (compat, green before and after: HIT, pinned data, hydration without the loader push).
  • Unit, next to each changed module (all red on 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' ] (over maxSnapshotBytes); expected undefined to deeply equal { handles: {} }.
    • router/match-middleware/__tests__/cache-lookup-prerender-shell-handles.test.ts (5): the three restore cases red with expected { restored: [ 'handler' ], … } to deeply equal { restored: [ 'handler', … ], … }; an entry without the record and a request that is not a shell HIT tail are controls.
  • Browser e2e, shared bodies in 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}:
    • runtime-captured shell (?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-note rows [], expected ["settled-only@g1"] (expectHitKeepsRows, index.ts:2384 on this branch; :2354 at 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).
    • build-time shell (bare path; dev: on-demand capture): same checks. Red in all 4 with the same assertion and errors.
    • promise push on Prerender + ppr: red in all 4: rows ["settled-note@g2", "deferred-note@g2"], expected ["settled-note@g1", "deferred-note@g2"] (expectPrerenderShellHitDeliversPromisePush, index.ts:2469; :2441 at the red run), plus the hydration error.
    • control (ppr without Prerender): 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.
  • Route-types HMR suite (Vite plugin code changed): 21/21 (one flaky overlay-timing test on the first run, 21/21 on the rerun; that suite runs the Node preset, which does not execute the changed Cloudflare branch).
  • E2E on the final tree, --no-deps, dev + production: router test-app files matching shell ppr prerender semantic-matrix: 417 passed (semantic matrix 53 of them); cloudflare-basic files matching ppr prerender: 305 passed, 1 skipped (the existing test.fixme in prerender-navigation.test.ts).
  • The first cloudflare run had 1 failure, 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 on origin/main's sources (5b7d02aa) alike. Cause: the dev and preview servers shared .wrangler/state, so the dev twin's updateTag("ppr-pp-evict-shell") marker, written after the build, evicted the production build-time shell. Fixed in its own commit (see Notes); the --workers=1 pair passes after it.
  • Client router chunk unchanged: router-YzEas1-z.js 142,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 + ppr route is served from (1) a shell captured at runtime (producer A: a URL with a query string, an SWR recapture after ppr.ttl), (2) the shell vite build bakes (producer B, shell-prerender-phase.ts -> captureShellForBuild), and in dev (3) the same producer B on demand through /__rsc_shell. All three go through settleCaptureRecord + captureAndStoreShell to write and serveShellHit -> yieldFromStore to 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 + ppr route on Cloudflare was rendered from the Prerender handler 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 whose Prerender handler 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 handles record 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 pins createRouter({ version }). Build-baked shells are rebuilt by every build.

  • serveShellRequest behavior change. A consumer test that served a Prerender route through serveShellRequest now 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/main twice 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's settleCaptureRecord change and this one touch different functions in shell-capture.ts.

  • cloudflare-basic e2e isolation (separate commit, test infra only). tests/cloudflare-basic/vite.config.ts gives vite preview its own Miniflare persistence (persistState: { path: ".wrangler/state-preview" } when isPreview), 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 on origin/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 shared restoreRecordHandles for the doc record and this record (both reviewers flagged the duplicated authority-then-restore sequence); HandleStore.getOwnedSegmentIds() replaces a full getData() walk at capture (the store is already sealed there by settleCaptureRecord, shell-capture.ts:1651, so nothing about sealing changes); a simpler artifact cache in serveShellRequest; 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 against maxSnapshotBytes (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 with shell-prerender-phase.ts (keeps plugin code untouched); a request-level store override in place of setPrerenderStoreForTests (serveShellRequest is sequential by contract). A reviewer raised a possible loop: with __PRERENDER_DEV_URL set in the Vite process, could the dev /__rsc_shell capture fetch a dev shell from itself? It cannot: fetchDevShellEntry is reached only from the document serve path (lookupBuildShell, rsc-rendering.ts), and the endpoint calls captureShellForBuild directly (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.

ivogt added 6 commits October 4, 2026 15:27
…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.
@pkg-pr-new

pkg-pr-new Bot commented Oct 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: c1e3a61

@ivogt
ivogt merged commit 3d70d19 into main Oct 4, 2026
43 checks passed
@ivogt
ivogt deleted the fix/1057-prerender-ppr-loader-pushes branch October 4, 2026 18:00
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.
@ivogt ivogt mentioned this pull request Oct 4, 2026
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 }).
@ivogt ivogt mentioned this pull request Oct 6, 2026
2 tasks done
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.

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)

1 participant