Skip to content

feat(router): on-demand (ISR-style) prerender - #640

Merged
ivogt merged 20 commits into
mainfrom
feat/ondemand-prerender
Oct 5, 2026
Merged

ivogt merged 20 commits into
mainfrom
feat/ondemand-prerender

Conversation

@ivogt

@ivogt ivogt commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Prerendering was build-time only. Once you shipped, the only way to refresh a
prerendered page's shared payload was to rebuild and redeploy the whole worker.
A CMS edit, a price change, or a webhook could not update a single product page
without a full deploy, and a running app had no way to say "re-render
/products/42 now and serve that to everyone." Consumers who needed this reached
for the runtime cache() layer, but that keys on host/search/custom generators
and its updateTag()/revalidateTag() silently no-op from a queue or cron
(they require an ALS request context), which are exactly the triggers ISR
refresh runs from.

What a consumer writes differently

Opt a route in (any truthy spelling works: literal, spread, or imported const):

export const ProductPage = Prerender(
  async () => [{ id: "featured" }],
  async (ctx) => <Product data={await ctx.env.PRODUCTS.get(ctx.params.id)} />,
  { onDemand: { ttl: 3600, tags: ({ params }) => [`product:${params.id}`] } },
);

tags also accepts a plain string[]. onDemand and the ppr path option
cannot be combined: the route definition throws.

Configure a store (same union shape as cache):

import { createKVPrerenderStore } from "@rangojs/router/prerender/cloudflare";

export const router = createRouter<Env>({
  prerender: (env) => ({
    store: createKVPrerenderStore(env.PRERENDER_KV),
    ttl: 3600, // soft: a stale entry still serves; entries never expire
    // optional; its presence is the stale-while-revalidate opt-in
    onRevalidate: (target, liveEnv) => liveEnv.PRERENDER_QUEUE.send({ target }),
  }),
});

Trigger a refresh from anywhere:

const prerender = router.prerender({ env, ctx }); // binds once, no work yet
await prerender("/products/42");
await prerender({ route: "products.detail", params: { id: "42" } });
await prerender("/products/43", { onlyIfStale: true, throwOnError: true });
await prerender.many(targets, { concurrency: 4 });
await prerender.markStale(["product:42"]);

concurrency defaults to 1; a large list belongs in a queue, one message per
batch, because one invocation runs under the platform's time and CPU limits.

Before vs. after (a CMS webhook)

// BEFORE: no way to refresh one page; a content edit meant a full redeploy,
// or dropping the route into the runtime cache() layer and losing the
// build-manifest fallback + queue/cron-safe invalidation.

// AFTER:
export async function POST(request: Request, env: Env, ctx: ExecutionContext) {
  const { productId } = await request.json();
  const result = await router.prerender({ env, ctx })(
    { route: "products.detail", params: { id: productId } },
    { throwOnError: true },
  );
  return Response.json(result); // { ok: true, status: "rendered", ... }
}

API changed during review (before vs. after, same snippet shape)

// BEFORE (first push of this PR):
createRouter({
  prerender: (env) => ({
    store: createKVPrerenderStore(env.PRERENDER_KV),
    defaultTtl: 3600,
    swr: true,
    onRevalidate: (target, e) => e.PRERENDER_QUEUE.send({ target }),
  }),
});
await router.prerender.invalidateTags(["product:42"], { env, ctx });
// custom store
const store: WritablePrerenderStore = {
  get: (key, { params }) => /* read, then re-verify version + params yourself */,
  set: (key, entry, { ttl, tags, params }) => /* compose the envelope yourself */,
  invalidateTags: (tags) => { /* ... */ },
};

// AFTER:
createRouter({
  prerender: (env) => ({
    store: createKVPrerenderStore(env.PRERENDER_KV),
    ttl: 3600,
    onRevalidate: (target, e) => e.PRERENDER_QUEUE.send({ target }),
  }),
});
await router.prerender({ env, ctx }).markStale(["product:42"]);
// custom store: plain get/set; the router composes and verifies the envelope
const store: WritablePrerenderStore = {
  get: async (key) => JSON.parse((await blob.get(serializePrerenderKey(key))) ?? "null"),
  set: async (key, stored) => blob.put(serializePrerenderKey(key), JSON.stringify(stored)),
  markStale: async (tags) => { /* optional */ },
};

PrerenderKey.buildId and the stored entry's meta.buildId are now version.
@rangojs/router/prerender no longer exports ResolvedPrerender,
PrerenderRuntimeBase, PrerenderLookupMeta or PrerenderSetOptions.

End-to-end (Cloudflare scheduled sweep + queue)

import type { PrerenderTargetObject } from "@rangojs/router/prerender";

export default {
  fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
    router.fetch(request, { env, ctx }),

  async scheduled(_e: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    const changed = await env.CMS.listRecentlyChangedProducts();
    ctx.waitUntil(
      router.prerender({ env, ctx }).many(
        changed.map((p) => ({ route: "products.detail", params: { id: p.id } })),
        { concurrency: 4 },
      ),
    );
  },

  async queue(
    batch: MessageBatch<{ target: PrerenderTargetObject }>,
    env: Env,
    ctx: ExecutionContext,
  ) {
    const targets = batch.messages.map((m) => m.body.target);
    ctx.waitUntil(
      router.prerender({ env, ctx }).many(targets, { concurrency: 8 }),
    );
  },
};

Semantics

Trigger results

Case Result / behavior
Route not onDemand { ok: false, status: "skipped-not-on-demand" }: a stray webhook can't freeze a route
No prerender store configured / prerender factory throws { ok: false, status: "no-store" } (factory error attached)
Target has ?search/#hash (v1 path-only) { ok: false, status: "skipped-unsupported-target" }
Unknown route / no match { ok: false, status: "no-match" }
Producer reads cookies()/headers()/client-cache directives { ok: false, status: "skipped-personalized" }, old entry kept
Render throws, notFound() included (#587 flag) { ok: false, status: "render-failed" }, old entry kept
Store write throws { ok: false, status: "store-failed" }, old entry + manifest kept
Passthrough+onDemand route returns ctx.passthrough() { ok: false, status: "skipped-passthrough" }, live handler keeps serving
Success { ok: true, status: "rendered", key, tags, ttl }, overlay replaced
onlyIfStale: true and the verified entry is fresh { ok: true, status: "already-fresh" }, no render (cron-sweep opt-in)
markStale(tags) entries carrying a tag marked stale; nothing deleted or re-rendered; dev warns once if no onRevalidate

What a request is served

Request finds Prerender(..., { onDemand }) Passthrough(def, live)
fresh overlay entry the overlay entry, loaders fresh (dev and production) same
stale overlay entry the overlay entry; onRevalidate scheduled if configured (once per key per isolate while in flight) same
no overlay entry, param baked production: the build entry; dev: the dev prerender render same
no overlay entry, param not baked production: 404; dev: rendered through the dev endpoint the live handler (dev and production)
server action re-render overlay, then build entry, then 404, as above; an overlay hit re-sends the route's own segments the live handler (the overlay is not read), unchanged
intercept navigation the bundled :i artifact (the overlay is not read; the store has no intercept key); a refresh-only param 404s in production unchanged

Unchanged on purpose: plain (non-on-demand) Prerender routes, including their
action re-renders (the bundled entry, client copy kept); Passthrough action
re-renders (live); updateTag()/revalidateTag() (never reach the prerender
store).

Code-change summary

New (pure/type-only where possible, no RSC deps on the store contracts):

  • src/prerender/writable-store.ts: WritablePrerenderStore (plain get/set,
    optional delete/markStale), the versioned PrerenderStoredEntry envelope,
    PrerenderKey (main variant only, no intercept slot), serializePrerenderKey,
    and the router-side composeStoredEntry / isStoredEntryValidFor /
    isStoredEntryStale plus the shared lowerStoredEntryStaleAt and
    readVerifiedStoredEntry helpers the stores and the router use.
  • src/prerender/on-demand.ts: PrerenderConfig, PrerenderResult, typed
    PrerenderTarget, the branded PrerenderTargetObject, PrerenderRuntime ({ env, ctx? }), PrerenderRunOptions, PrerenderManyOptions, PrerenderRunner, PrerenderFn, OnDemandOption, and the
    internal ResolvedPrerender.
  • src/prerender/memory-prerender-store.ts: in-memory store (dev default / test
    fake / single-process node).
  • src/prerender/cloudflare.ts: createKVPrerenderStore
    (@rangojs/router/prerender/cloudflare).
  • src/prerender/create-prerender-trigger.ts: the pure, injected-deps trigger
    factory: (runtime) => runner with refresh / many / markStale, PrerenderError.
  • src/prerender/producer-guard.ts: PrerenderPersonalizationError +
    assertNotInsidePrerenderProducer.
  • src/prerender/index.ts: @rangojs/router/prerender public entry.

Edited:

  • router-options.ts / router-interfaces.ts: prerender option +
    router.prerender binder, _prerenderConfig.

  • router.ts: build + attach the trigger. The key version comes from
    resolvePrerenderVersion(routerId, version) per call; the trigger shares
    isDevEnvironment() with the producer gate.

  • server/build-version-table.ts: resolvePrerenderVersion, the one call both
    sides use: the router's data version, or createRouter({ version }).

  • rsc/handler.ts: resolve the store per request onto
    RequestContext._prerender, keyed with resolvePrerenderVersion(router.id, router.version) resolved per request (the trigger resolves it per refresh, once
    per .many() batch).

  • router/match-middleware/cache-lookup.ts: overlay read before the bundled
    manifest in tryPrerenderLookup, verified by the router; gate widened to
    pr || od, actions included for plain on-demand routes; yieldFromStore's
    replaceable flag re-sends route segments on an action overlay hit;
    scheduleOverlayRevalidation dedups onRevalidate per isolate (an entry older
    than IN_FLIGHT_LEADER_MAX_WAIT_MS counts as free; skipped under ctx.build).
    The key block:

    if (overlay && ctx.matched.od && !ctx.isIntercept) {
      const key = { routerId: overlay.routerId, version: overlay.version, routeName, paramHash };
      const stored = await readVerifiedStoredEntry(overlay.config.store, key, ctx.matched.params);
      if (stored) {
        if (overlay.config.onRevalidate && reqCtx && isStoredEntryStale(stored, Date.now()))
          scheduleOverlayRevalidation(serializePrerenderKey(key), overlay.config.onRevalidate, target, reqCtx);
        yield* yieldFromStore(stored.entry, ..., /* replaceable */ true);
        return true;
      }
    }
  • router/prerender-match.ts + router/handler-context.ts: onDemand flag
    arms the personalization guard, sets ctx.onDemand, returns the route's
    onDemand config, and carries the router's cache versions (_versions).

  • server/request-context.ts (_prerender, _onDemandProducer) +
    server/cookie-store.ts (guard wired into standalone cookies()/headers()
    and the client-cache directives).

  • urls/path-helper.ts: the isOnDemand stamp, gateOnDemandProducer (no
    in-request producer render in production), and the ppr + onDemand throw.

  • prerender.ts (PrerenderOptions.onDemand, BuildContext.onDemand),
    server/context.ts (EntryData.isOnDemand), the od trie pipeline
    (generate-manifest.ts -> route-trie.ts -> trie-matching.ts/
    find-match.ts/pattern-matching.ts -> serve gate; regex-fallback
    onDemandRouteKeys in types/route-entry.ts + router.ts;
    discover-routers.ts).

  • vite/utils/bundle-analysis.ts + vite/router-discovery.ts +
    vite/discovery/state.ts: producer retention driven by the discovery-derived
    $$id set, the same source as the runtime od flag.

  • testing/index.ts + package.json exports.

  • tools/check-docs-api.mjs: recognizes non-generic interface declarations.

Why these locations: the prerender option mirrors cache (per-request
factory resolution, never at createRouter() time); the trie od flag mirrors
pr/pt; the overlay read reuses yieldFromStore (loaders resolve fresh,
unchanged); retention reuses the eviction-skip mechanism (the passthrough
precedent). Verification sits in the router, not the store, so a third-party
store cannot skip the collision guard. The key version sits next to
resolveRouterVersions, the module that already answers "which versions does
this router use".

Test plan

  • Unit, src/prerender/__tests__/: trigger (target resolution, every
    status, onlyIfStale incl. a read failure and an entry that fails
    verification, throwOnError, many/concurrency, markStale incl. the
    no-onRevalidate dev warning, static tags arrays), envelope
    (compose, verify: shape, version, params, missing tags/storedAt), memory
    store (plain round-trip, no miss memoization, markStale never moves an
    earlier staleAt later), KV store (tag-marker mark-stale, no KV expiry,
    corrupt value, malformed values returned for the router to reject).
  • Unit, src/testing/__tests__/prerender-dogfood.test.ts: eligibility and
    config paths through a real createRouter(); ppr + onDemand throws
    at route definition.
  • Userland, src/testing/__tests__/on-demand-prerender.rsc-test.tsx
    (router.prerender({ env })(...) then serveShellRequest(), real Flight serializer,
    production request handler). Pins: the stored payload serves while the
    loader's value changes; skipped-personalized on cookies(); the router
    verifies a store's answer (a naive store does not serve one param set's
    entry under another URL); one key version on both sides with
    createRouter({ version }) and with the build's per-router data version,
    surviving a client-only deploy and dropped by a server deploy;
    getCacheVersions() inside a refresh returns the router's versions with
    no outside-a-request warning; onRevalidate scheduled once per stale key
    while one is in flight, again after it settles.
  • Red before green (each run on the code without the change):
    • with the router's data version from the build: AssertionError: expected 'h-app' to be 'd-app'
    • schedules onRevalidate once per stale key while one is in flight: AssertionError: expected "vi.fn()" to be called 1 times, but got 2 times
    • a cache key built inside a refresh carries the router's versions (producer _versions removed): expected '1:"/Users/…' to contain 'versions:d-app/h-app', plus the [rango] A cache key was built outside a request warning
    • the router verifies a store's answer (serve-path verification removed): expected '1:"/Users/…' not to contain 'a:stamp-1'
  • E2E dev + production, test-app (node, memory store) and cloudflare-basic
    (KV store): live -> trigger -> overlay serve, loaders fresh on an overlay
    hit, build-baked param still served, personalized producer skipped,
    plain-route 404 until refreshed (production) / dev fall-through,
    onlyIfStale + markStale (KV tag markers end to end), stale overlay
    schedules onRevalidate, and two new cases per app per mode:
    • server action on an overlay-only page re-renders from the overlay
    • router.prerender() inside a server action: the re-render shows the refreshed entry
  • Red before green for the action cases (pre-fix tree, --retries=0):
    • test-app (production) overlay-only action: od-action-noop-status stayed idle (the re-render 404'd)
    • test-app (production) refresh in action: od-action-refresh-status stayed idle
    • test-app (dev) refresh in action: Expected: not "2026-10-04T13:07:31.429Z" / Received: "2026-10-04T13:07:31.429Z" (client kept its copy)
    • cloudflare-basic (production) overlay-only action: gp-action-noop-status not noop:…
    • cloudflare-basic (production) refresh in action: gp-action-refresh-status not rendered:…
    • cloudflare-basic (dev) refresh in action: Expected: not "2026-10-04T13:08:45.198Z:3f1w2i4w" / Received: same
    • The overlay-only action case passes before the fix in dev in both apps:
      the dev prerender endpoint renders any param, and the store-hit path
      keeps the client's segments, so the bug is production-only for that case.
  • Bundle retention e2e: the onDemand producer is retained in the RSC bundle
    (both apps); client router chunk 47489 B gzip (limit 48128 B).
  • Typecheck (16/16), test:unit:all (router 7646 passed / 5 skipped /
    6 todo; router RSC 523 passed / 2 expected fail; cloudflare-basic 69 + 17;
    mini 25 + 3; vite-rsc-demo 25 + 1; shared-e2e 8), lint, format,
    check:e2e-bucketing, check:e2e-parity --strict, check:docs-api,
    check:bundle-guards.
  • E2E on the final tree, dev + production, --no-deps, --retries=0:
    • test-app, files matching prerender on-demand ondemand shell ppr cache-version semantic-matrix (428 tests, 22 files): 427 passed, 1 failed:
      shell-capture-timeout (dev) › budget shorter than the settlement sequence refuses with apiRequestContext.get: read ECONNRESET on its own server;
      re-run alone, the file passed 4/4.
    • cloudflare-basic, files matching prerender on-demand ondemand ppr cache-version (317 tests, 18 files): 313 passed, 1 skipped, 3 failed.
      ppr-shell caching (production) › updateTag evicts the build-time shell
      (Expected: "HIT" / Received: "MISS") fails the same way on origin/main
      (5b7d02a) with ppr-shell + prerender-passthrough +
      ppr-capture-timeout, dev + production (157 passed, 1 failed), and passes
      when run alone in both trees: the dev and production servers share the
      miniflare KV, and the dev run of the same test writes the tag marker. The
      other two, ppr capture timeout (dev) › budget shorter… (ECONNRESET) and
      prerender passthrough (dev) › cross-route navigation in dev mode, passed
      on re-run (and in a 4-file branch re-run: 175 passed, 1 failed, the
      ppr-shell case).

Independent review

An API review after main was merged in found seven must-fix and seven
should-fix items; all fourteen were applied in this PR:

  • Action re-renders of plain on-demand routes skipped the overlay (404 on an
    overlay-only param in production, the older build payload on a baked one).
    Fixed; an action overlay hit also re-sends the route's segments so a refresh
    inside the action is visible.
  • The overlay was keyed on the document version, so a client-only deploy wiped
    every refreshed page. It now keys on the data version through one shared
    resolution; buildId is renamed version.
  • PrerenderConfig became { store, ttl?, onRevalidate? } (swr removed,
    defaultTtl renamed); invalidateTags became markStale; the public entry
    dropped internal types.
  • The skills documented nothing about on-demand prerender; four skills now do,
    with the serve table and the rules.
  • The refresh-then-serve path is reachable from @rangojs/router/testing. The
    review asked for it through dispatch(); dispatch renders response routes
    only (it throws on a component route) and the overlay is read in the RSC
    match pipeline, so it cannot serve an on-demand page. serveShellRequest
    already runs the production handler, which resolves the prerender config,
    so the dogfood uses it and no primitive changed.
  • Stores became plain get/set with router-side verification; onRevalidate is
    deduped per isolate; dev warns on markStale with no onRevalidate; the
    trigger and the producer gate share isDevEnvironment(); ppr + onDemand
    throws; tags accepts arrays; the producer _versions wiring has a test;
    the test-app fixture comment on the typed target was corrected.

Two review premises did not hold and are noted rather than worked around:
dispatch() (above), and the fixture comment: the typed { route, params }
target still does not typecheck against the test-app router, for a different
reason than the comment gave (its accumulated route map has a string index
signature, so the mapped target union collapses to never params;
router.reverse() infers the name and is unaffected). Also, cache() and
"use cache" do not consult a store inside a refresh at all (the producer
context carries no _cacheStore), so the _versions test pins
getCacheVersions(), the call every versioned store makes per key.

Notes / call-outs

  • ctx.env inside a producer render is the live trigger binding, not the
    shared build buildEnv. A handler relying on build-only bindings behaves
    differently under refresh.
  • A trigger may render params getParams() never returned. Validate ids from
    webhooks before refreshing.
  • A refresh cannot reach connected clients: a client that prefetched the page
    keeps its copy for prefetchCacheTTL (300 s default) and the browser HTTP
    cache (prefetchCacheControl).
  • Each request to an on-demand route on KV pays one KV read plus one per tag on
    the entry (no marker memo in v1).
  • createRSCHandler({ version }) does not move the overlay key: both sides key
    on createRouter({ version }) or the build's data version, so writes and
    reads cannot disagree.

Follow-ups (to be filed)

  • Intercept-variant refresh (an on-demand route used as an intercept target
    serves the build :i artifact, 404 on a refresh-only param).
  • Delete / tombstone through the trigger.
  • Negative caching for overlay misses, and a KV tag-marker memo.
  • KV marker TTL and router-id scoping of markers.
  • A Vercel store.
  • A warning when createRSCHandler({ version }) and createRouter({ version })
    disagree.
  • E2E for .many(), render-failed, skipped-passthrough.
  • The mapped object target collapses on a route map with a string index
    signature (test-app). A generic-name signature like reverse's would fix it.
  • Build-time durable seeding (design phase 8).

Review pass

Applied (commit 1, cleanup; commit 2, typing):

  • onRevalidate dedup cannot pin a key: entries carry a start time and count as
    free past IN_FLIGHT_LEADER_MAX_WAIT_MS (the runtime cache's leader cap, moved
    to cache-policy.ts so cache-lookup does not import cache-runtime);
    scheduling is skipped under ctx.build; resetShellTestState() clears the map.
    Eviction unit-tested.
  • One version cadence: the handler resolves resolvePrerenderVersion per request.
    The serveShellRequest handler cache now keys on the document version only (the
    handler still binds that one), so a data-only change reuses the handler. The new
    a long-lived handler follows a data version change test fails with the old
    per-handler resolution.
  • ppr + onDemand throws before the forRoute early return.
  • Shared helpers lowerStoredEntryStaleAt and readVerifiedStoredEntry; the KV
    get drops the shape re-checks, tolerates a missing meta, and skips marker
    reads for an already-stale entry.
  • Trigger tags go through normalizeTagList (now exported, and de-duplicating,
    which cache({ tags }) also gets); non-string tags are filtered first.
  • The unused intercept slot and :i branch are removed from PrerenderKey;
    main-variant keys serialize as before (asserted).
  • Both trigger dev warnings fire once per trigger; the passthrough probe is
    computed once per request; .many() resolves the version once; history-
    narrating comments cut.
  • PrerenderTargetObject carries a type-only brand and PrerenderTarget accepts
    it, so onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target)
    typechecks with no cast, queue JSON round trip included; a wrong route name or
    params is still an error. Pinned in
    tests/cloudflare-basic/src/prerender-target-types.ts (@ts-expect-error
    negatives); casts removed from the skills and design doc.

Not applied (decided): moving the dedup into the trigger (it would stop deduping
queue sends); replacing yieldFromStore's replaceable flag with a shared
predicate; a per-isolate memo for KV tag markers (tracked in #1060); switching
the trigger's dev flag back to __PRERENDER_DEV_URL (the isDevEnvironment()
unification was approved).

Second run by the reviewer on the final commit (after pnpm build-router): gate green (router 7658 unit, 524 RSC with 2 expected failures; typecheck, lint, format, bucketing, parity --strict, docs-api 279 identifiers, bundle guards); e2e --no-deps over every prerender / on-demand file (HMR excluded): router app 84 passed in dev and 108 in production, cloudflare-basic 36 passed (1 existing skip) in dev and 107 in production; semantic matrix 25 dev, 28 production.

How this PR was finished: step 1 merged 214 commits of main into the July branch and resolved 17 conflicts keeping both sides; step 2 was an independent overall API review (verdict: sound, finish it; the work list and the six maintainer decisions are in "Independent review" above); step 3 applied it; a code-quality review of step 3 followed ("Review pass"). Follow-ups placed after the merge are tracked in #1060.

Changed on the maintainer's request after the review pass: router.prerender
now binds the runtime once and returns a runner; per-call options stay per call.
The runtime type holds only { env, ctx? }; onlyIfStale / throwOnError
(and concurrency on .many()) moved to the runner's per-call options.
Binding does no work; the config factory, version and manifest still resolve per
call, .many() keeps its bounded pool, input order, concurrency default 1 and
one version per batch, and the markStale dev warnings still fire once per
router. onRevalidate now also receives the stale request's ctx as a third
argument (absent where none exists), so the documented one-liner binds the runner
with it; pinned in prerender-target-types.ts and the dogfood's
toHaveBeenCalledWith.

// BEFORE:
await router.prerender("/products/1", { env, ctx, onlyIfStale: true, throwOnError: true });
await router.prerender.many(["/a", "/b"], { env, ctx, concurrency: 4 });
await router.prerender.markStale(["product:1"], { env, ctx });
onRevalidate: (target, env) => router.prerender(target, { env }),

// AFTER:
const prerender = router.prerender({ env, ctx });
await prerender("/products/1", { onlyIfStale: true, throwOnError: true });
await prerender.many(["/a", "/b"], { concurrency: 4 });
await prerender.markStale(["product:1"]);
onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target),

Merged with main after #1061 (the #1057 fix: a Prerender + ppr shell HIT restores its loader pushes). One textual conflict, resetShellTestState() in src/testing/serve-shell-request.ts, kept both resets. One interaction: #1061 made serveShellRequest bake a build-time artifact for every Prerender route on its first request; for an on-demand route that ran the producer per request and hid the overlay, which a build does not do (it bakes an on-demand route only for the params getParams() lists). serveShellRequest now bakes nothing for an on-demand route (matched.od), so a refresh-only page serves from its prerender store alone, as in production. No overlap in the serve path: ppr and onDemand cannot combine, so a handles record (#1061) never meets an overlay hit.

Run by the reviewer on the merged commit (after pnpm build-router): gate green (router 7676 unit, 529 RSC with 2 expected failures; typecheck, lint, format, bucketing, parity --strict, docs-api 279 identifiers, bundle guards); e2e --no-deps over every prerender / on-demand file plus shell-push-ownership / ppr-push-ownership and the semantic matrix: router app 119 passed in dev and 146 in production, cloudflare-basic 46 passed (1 existing skip) in dev and 117 in production.

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.
@ivogt
ivogt force-pushed the feat/ondemand-prerender branch from 9dea9d9 to 21d2ac7 Compare July 3, 2026 05:17
@ivogt

ivogt commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed an in-depth code review (11 confirmed findings)

Amended into the single feature commit. Fixes:

Blockers

  1. Personalization error baked as rendered — a deep async child calling cookies() throws during Flight serialization (not resolveSegment), where React embedded it as an error row and completed the stream. serializeSegments/rscSerialize now take an optional onError; the on-demand producer captures it and surfaces a PrerenderPersonalizationError → skipped-personalized, old entry kept. Narrowed to personalization errors only, so a legitimate error/notFound boundary still bakes its fallback (as build-time prerender does).
  2. node/Vercel version not forwarded — the virtual entry hard-coded version: VERSION, so a custom createRouter({ version }) made every overlay read miss (buildId mismatch). Now forwards router.version ?? VERSION, mirroring the adjacent nonce fix.
  3. Serve-path overlay read unguarded — a transient store error 500'd every on-demand route. Now try/caught → degrade to a miss (manifest/live fallback), mirroring the trigger's onlyIfStale guard.
  4. Throwing tags() callback aborted a many() batch — moved inside the store guard → store-failed per target.
  5. Parseable-but-malformed durable value crashed the serve path — isStoredEntryValidFor now shape-checks (null / missing meta) before dereferencing; KV tags access guarded.

Below-cap: NaN/negative ttl → never-stale (not thrashing); invalid many() concurrency → defaults to 1; removed the unused KVPrerenderStoreOptions.ctx (pre-release API hygiene).

Tests: +regressions for each (malformed value, ttl/concurrency guards, batch-safe tags) and a userland dogfood through a real createRouter() + the public createMemoryPrerenderStore() (eligibility / no-store / no-match / unsupported-target / invalidateTags). The producer render path stays e2e-only — the bare Vitest RSC project can't resolve the serializer's virtual: modules.

Deferred (documented in the design doc + plans/): build-time literal cross-check for onDemand (review #4), intercept-variant staleness call-out (#5), an e2e personalization case (#1), and routing SWR onRevalidate failures through background-error reporting.

Full gate re-run green: typecheck 13/13, test:unit:all (router 4599 + all consumer apps), lint 0/0, format.

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
@ivogt
ivogt force-pushed the feat/ondemand-prerender branch from 21d2ac7 to 84010cc Compare July 3, 2026 06:14
@ivogt

ivogt commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review follow-ups (amended)

Addressed the 3 remaining items from the re-review:

  • SWR onRevalidate failures no longer swallowed — routed through the request context's _reportBackgroundError with the stale-revalidation category (matching the runtime cache's SWR path), so a broken queue binding surfaces via onError instead of vanishing.
  • setStatus doc claim corrected — the _onDemandProducer comment now accurately states response mutations (ctx.setStatus/header/setCookie) stay silent no-op stubs during a producer render (the design's "throw or skip" permits skip; there's no real request to leak), while the standalone reads (cookies()/headers()) throw.
  • od-detection mismatch (finding 4) — explicitly accepted in the design doc (per the reviewer's offered path) + spelled out in the PrerenderOptions.onDemand JSDoc: a computed onDemand value gets od: true but is evicted → render-failed, so onDemand must be a literal. The proper closing move (a build-time error when the manifest onDemand-set and the regex retention-set disagree, joinable via prerenderDef.$$id) is a tracked follow-up.
  • Minor — version-injector.ts custom-entry version divergence (version-less custom entry + createRouter({ version })) noted as a one-line follow-up; the common paths are covered by the virtual-entries fix.

Gate re-run green across all packages (router 4599 + cloudflare-basic 46+10 + mini + demo, typecheck 13/13, lint 0/0, format).

@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 1c6720f

ivogt added 6 commits July 11, 2026 14:42
- 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.
ivogt added 7 commits October 4, 2026 16:18
…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.
ivogt added 4 commits October 4, 2026 21:00
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.
@ivogt
ivogt merged commit 8a46227 into main Oct 5, 2026
43 checks passed
@ivogt
ivogt deleted the feat/ondemand-prerender branch October 5, 2026 13:45
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 added a commit that referenced this pull request Oct 9, 2026
CHANGELOG: ## Unreleased -> ## 0.22.0 (2026-10-09), plus the entries #640 merged without (on-demand prerender; cache({ tags }) drops duplicate tags). package.json 0.22.0.
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.

1 participant