Skip to content

feat(router): per-app cache versions, a deploy keeps the cache of code it did not change - #1034

Merged
ivogt merged 12 commits into
mainfrom
feat/per-app-cache-version
Oct 4, 2026
Merged

ivogt merged 12 commits into
mainfrom
feat/per-app-cache-version

Conversation

@ivogt

@ivogt ivogt commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every production build got a new version, the build time
(Date.now().toString(16) in the version plugin), and every persistent cache
key and every stored PPR shell carried it. A consumer saw three things:

  • every deploy started with a cold cache, whatever it changed;
  • a rebuild of unchanged source (a CI retry, a redeploy, a rollback) cleared
    the cache too: measured on tests/cloudflare-stress-demo, /app/cached/hot
    was a hit after a server restart on the same build and a miss after
    rebuilding the same source;
  • under a host router, deploying one app cleared the cache of every other app
    in the worker.

What a consumer sees now

vite build computes two versions for each createRouter() from that
router's built code and prints them:

[rango] Cache versions for 2 router(s), data / document (9.8ms):
[rango]   141feb48ee9301cb / c1e538fdfeb8470a  src/apps/a/router.tsx
[rango]   a04e7def0c013c33 / f22c12acd5ee9ed9  src/apps/b/router.tsx
  • The data version is a hash of the router's server code. It keys cached
    RSC data: cache() segment entries, "use cache" values, loader data.
  • The document version is the data version plus the SSR output, the
    client asset file names, base and the router's Prerender payloads. It
    keys stored HTML (PPR shells, document-cache responses), stamps shells, is
    sent as metadata.version, and is what an open tab's _rsc_v is compared
    with.

The same code builds to the same versions, on any machine and in any checkout
directory, so a new deployment finds the entries the previous one wrote.

Before and after

No API is needed to get the new behavior. What changes is what the same
requests return across a deploy.

Before:

build 1   1a101ad4ae4
          GET /app/cached/hot -> 1791029499936   (stored)
          GET /app/cached/hot -> 1791029499936   (hit)
restart   GET /app/cached/hot -> 1791029499936   (hit)
build 2   1a101adbfc4                            (same source; the version is the build time)
          GET /app/cached/hot -> 1791029529495   (miss)

After (pnpm --filter cloudflare-stress-demo bench:retention, this branch):

build 1   aa0e4ac8b921d1c9 / 1c86a03ad46604c1
          GET /app/cached/hot -> 1791058181368   (stored)
          GET /app/cached/hot -> 1791058181368   (hit)
restart   GET /app/cached/hot -> 1791058181368   (hit)
build 2   aa0e4ac8b921d1c9 / 1c86a03ad46604c1   (same source)
          GET /app/cached/hot -> 1791058181368   (hit)
build 3   12461c4786a9d057 / f0c8fd830c5cb52e   (server code of this router changed)
          GET /app/cached/hot -> 1791058193306   (miss)

The one place a consumer writes something different is the stable encryption
key, and the Vercel store wiring:

// vite.config.ts, before
rango({ preset: "vercel" });
// RANGO_ENCRYPTION_KEY, if set, was read from the environment by the plugin

// vite.config.ts, after
rango({
  preset: "vercel",
  encryptionKey: process.env.RANGO_ENCRYPTION_KEY, // validated at config time
});
// router.tsx, before: a per-deploy namespace, the cache is cleared on every deploy
const runtimeCache = getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID });

// router.tsx, after: the store versions its keys per router
const runtimeCache = getCache();

End-to-end example

A host router with two apps (Cloudflare)

// vite.config.ts
export default defineConfig({
  plugins: [
    react(),
    rango({
      preset: "cloudflare",
      encryptionKey: process.env.RANGO_ENCRYPTION_KEY,
    }),
    cloudflare({
      configPath: "./wrangler.json",
      viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
    }),
  ],
});
// src/worker.rsc.tsx
import { createHostRouter } from "@rangojs/router/host";

const hostRouter = createHostRouter();
hostRouter.host(["shop.example.com"]).lazy(() => import("./apps/shop/handler.js"));
hostRouter.host(["blog.example.com"]).lazy(() => import("./apps/blog/handler.js"));

export default {
  fetch: (request, env, ctx) => hostRouter.match(request, { env, ctx }),
} satisfies ExportedHandler<Env>;
// src/apps/shop/router.tsx (blog is the same shape)
import { createRouter } from "@rangojs/router";
import { CFCacheStore } from "@rangojs/router/cache";

export const router = createRouter<Env>({
  document: Document,
  // No `version`: the store keys with this router's own cache versions.
  cache: (env, ctx) => ({ store: new CFCacheStore({ ctx: ctx!, kv: env.KV }) }),
}).routes(urlpatterns);
// src/apps/shop/urls.tsx
export const urlpatterns = urls(({ path, cache }) => [
  cache({ ttl: 600 }, () => [path("/products/:id", ProductPage, { name: "product" })]),
  path("/", HomePage, { name: "home", ppr: { ttl: 600 } }),
]);

Deploy a change to apps/blog: the build prints a new pair for
src/apps/blog/router.tsx and the same pair as before for
src/apps/shop/router.tsx. Shop's /products/:id entries and its / shell
are served from the store; shop's open tabs keep navigating without a reload;
blog starts cold and blog's tabs reload on their next navigation.

A single app (node or Vercel)

// vite.config.ts
export default defineConfig({
  plugins: [
    react(),
    rango({ preset: "vercel", encryptionKey: process.env.RANGO_ENCRYPTION_KEY }),
  ],
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
});
// src/router.tsx
import { getCache, waitUntil } from "@vercel/functions";
import { VercelCacheStore } from "@rangojs/router/cache";

const runtimeCache = getCache();

export const router = createRouter({
  document: Document,
  cache: () => ({ store: new VercelCacheStore({ cache: runtimeCache, waitUntil }) }),
}).routes(urlpatterns);

A single app is one router: a redeploy of unchanged code, a docs-only commit,
a CI config change or a rollback keeps the cache; a server change clears it; a
client-only change keeps cached data and replaces stored HTML.

To clear the cache on demand, or to keep "every deploy clears":

createRouter({ version: "2026-10-03.1" }); // change it to clear
createRouter({ version: process.env.BUILD_ID }); // the old behavior

Semantics

Verified end to end on the node preset (packages/rangojs-router/e2e/cache-version.test.ts,
VercelCacheStore over a file-backed Runtime Cache handle) and on Cloudflare
(tests/cloudflare-basic/e2e/cache-version.test.ts, CFCacheStore with KV
under miniflare persistence). Both run one scenario: a host router with two
apps, four builds, a server restart on each, open tabs. The Vercel preset is
verified at build level only (see Notes).

Deploy Cached data Stored HTML Open tabs Verified by
Server restart, same build kept kept n/a e2e node + CF, stress-demo bench
Rebuild or redeploy, no code change kept kept untouched e2e node + CF, stress-demo bench, build test
Server code of app A changes cleared for A only cleared for A only A's tabs reload, B's do not e2e node + CF, build test
Route added to app A cleared for A only cleared for A only as above build test
Client code of any app changes kept for every app cleared for every app every app's tabs reload e2e node + CF, build test
Server module shared by A and B changes cleared for A and B cleared for A and B both reload build test
Same source, another checkout directory kept kept untouched build test
Same source, vite build <app> from another working directory kept kept untouched build test; two-directory builds of three apps
A bundled dependency reinstalled against other peers (pnpm store directory renamed) kept kept untouched unit (build-versions.test.ts)
A blank line or a comment added above createRouter() kept; the router id, its state cookie and its manifest chunk keep their names kept untouched build test; unit (expose-router-id.test.ts)
Encryption key changes cleared for routers that encrypt with it, kept for the others same same build test
Host entry changes (lazily mounted apps) kept kept untouched build test
App A adds a server action cleared for A only cleared for A only A's tabs reload build test
Code only app B's mounted handler reaches changes cleared for B only cleared for B only B's tabs reload build test
A stylesheet a server component of A imports changes cleared for A only cleared for every app every app's tabs reload build test
A dependency B leaves external is bumped (node preset), or one of its dependencies cleared for B only cleared for B only B's tabs reload build test
An import B leaves external that the build cannot read (a package not in node_modules, a path that does not resolve, a URL) cleared for B on every build, with a build note same same unit (build-versions.test.ts, router-versions-phase.test.ts)
A file B's chunk imports by path and leaves external changes cleared for B only cleared for B only B's tabs reload unit (both)
A build output file missing on disk, an unrecorded bundle, a missing key file while code encrypts the build fails unit (both)
A deferred cache write (ctx.waitUntil) run outside the request's async context keyed with the serving router's version same n/a background-write-versions.rsc-test.tsx
A clientUrls() route of B declares another loader cleared for B only cleared for B; the other apps follow the client assets, as in the client-code row B's tabs reload build test (data versions)
A tab of app A navigates into app B (host router) n/a n/a full document load (app switch), whatever version the tab carries unit (request-classification.test.ts), e2e host project
Route manifests as workerd Text modules, no change (Cloudflare) kept kept untouched e2e CF (RANGO_MANIFEST_TEXT=1), stress-demo bench
New Prerender content, same code kept cleared for that app that app's tabs reload unit (build-versions.test.ts)
New Static() content, same code cleared for the app cleared for that app that app's tabs reload unit (build-versions.test.ts)
updateTag() under deploy B, rollback to A A's tagged entries are stale same n/a unit (both stores)

Deliberately unchanged:

Case Behavior
Dev server One version stamp, bumped on every RSC module edit. ROUTER_VERSIONS is undefined. HMR suites unchanged.
createRouter({ version }), createRSCHandler({ version }) That exact value for both versions, as before. Now also honored on the node and vercel presets (see Notes).
new CFCacheStore({ version }), new VercelCacheStore({ version }) That exact value prefixes every versioned key, as before.
MemorySegmentCacheStore No version; the process is its scope.
Prerender and Static stores Ship with the build, no version.
The freshness comparison for tags Unchanged: marker time against the time the entry's tags were attached. Only where the marker is stored changed.
createCloudflareZonePurge, Vercel expireTag Already version-free (rg:{ns}:e:{tag} / the consumer's tags).

Code change

Paths are under packages/rangojs-router/. The design, with what the
proposal got wrong, is docs/design/per-app-cache-version.md.

Build: which code is a router's, and what is hashed

src/vite/discovery/build-versions.ts (new, pure over data the hooks record):

  • resolveRouterMembers: membership on the bundler's MODULE graph. The walk
    starts at the createRouter() module and every module that statically
    imports it, follows static and dynamic imports, and stops at the build's
    registries (plugin-rsc's server-reference map, the loader manifest, the
    routes manifest), which name every app. An action or loader only a client
    component imports is attributed through the CLIENT graph.
  • ownersOf: the one ownership rule for everything that is not a chunk the
    walk reaches (a registry target, a Static payload, a clientUrls()
    projection, a server component's stylesheets, the encryption key): it
    belongs to the routers that reach one of its subject modules, and to every
    router when none does.
  • digestBundle: each file digested once, with what says where or how the
    build ran taken out first: the hashed names of bundle files (kept as an
    ordered list of referenced files), the server-reference map's body, and the
    paths in region comments. The RSC and the SSR output both go through it.
  • computeRouterVersions: per router, [file digest, ...references as the target's identity when owned, one token when not] for each owned file, plus
    what its chunks leave external (classifyExternal) and the inputs it owns.
const data = hashInputs("rango-data-version", dataInputs);
const document = hashInputs(`rango-document-version\n${data}`, documentInputs);
table[routerId] = [data, document];

One rule for what the build cannot determine: it fails, or the version changes
on every build. It never silently stays the same.

The build cannot determine What happens
a bundle (RSC, SSR, client) that was not recorded the build fails
a listed file that is not on disk, or cannot be read the build fails
plugin-rsc's assets manifest the build fails
the chunk holding the version module, or its placeholder the build fails
the key file, while a module encrypts the build fails
which module encrypts, while there is a key file the key is in every router's version
an external import: a package not installed, a path that does not resolve, a URL a per-build value for the routers importing it; the build names it
which router runs a registry target, a payload's handler, a clientUrls() module, a server component's CSS it is in every router's version

src/vite/discovery/router-versions-phase.ts (new) runs in the buildApp
post hook and hands the table to the build-time shell capture:

buildApp: {
  order: "post",
  async handler(builder) {
    try {
      await runShellPrerenderPhase(
        s,
        builder as any,
        runRouterVersionsPhase(s, builder as any), // compute, fill, print, report
      );

A version cannot be a constant in the code it hashes, so the built
@rangojs/router:version module is byte-identical in every build and holds a
free identifier the phase replaces in the written chunk
(getVirtualBuildVersionContent, src/vite/plugins/virtual-entries.ts):

export const ROUTER_VERSIONS = __RANGO_ROUTER_VERSIONS__;
export const VERSION = ROUTER_VERSIONS["*"][1];

The placeholder is filled in every environment's output that holds the
module, not only the RSC one.

Determinism (each one measured):

  • build stamp removed (the placeholder above);
  • region paths out of the hash input: the working directory, the pnpm store
    directory with its peer suffix, absolute paths in virtual ids;
  • module ids made portable (portableModuleId): root-relative, the pnpm store
    directory reduced to name@version, plugin-rsc's client-reference group id
    of a virtual facade without the working directory;
  • $$sourceFile root-relative in a build, and the router $$id a hash of the
    root-relative path and the call's position among the file's
    createRouter() calls (routerId,
    src/vite/plugins/expose-ids/router-transform.ts), not of its line;
  • rango({ encryptionKey }), validated in configureEncryptionKey
    (src/vite/encryption-key.ts);
  • src/vite/plugins/stable-rsc-output.ts (new): sorts plugin-rsc's
    server-reference map and gives its assets-manifest import one local name;
  • loader manifest and client-URL projections sorted
    (expose-internal-ids.ts, discovery/virtual-module-codegen.ts);
  • node and vercel RSC entry built with preserveEntrySignatures: "strict"
    (RSC_ENTRY_SIGNATURES, src/vite/rango.ts), as @cloudflare/vite-plugin
    already forces.

Runtime: one resolution, per-family keys

src/router-versions.ts and src/server/build-version-table.ts (new):

export function resolveRouterVersions(routerId, override?): RouterVersions {
  return (
    resolveVersionsFrom(table, routerId, override) ?? uniformVersions(VERSION)
  );
}

createRSCHandler (src/rsc/handler.ts) resolves its router's pair once. Its
own version is the document version (payload metadata, _rsc_v check, shell
stamp and gate, rango state value). The data version reaches the stores on the
request context (_versions), read per operation:

export function versionKeyPrefix(storeVersion, kind: KeyVersion): string {
  if (kind === null) return "";
  const version = storeVersion ?? getCacheVersions()[kind];
  return version ? `v/${version}/` : "";
}
  • CFCacheStore: keyToRequest(key, family) / toKVKey(key, family);
    segments and fn: items "data", doc: and shell2: "document", tag
    markers null (KV key, edge marker request, isolate memo key).
  • VercelCacheStore: FAMILY_VERSION (s, i data; r, h document;
    tm none), now on by default.
  • src/rsc/shell-build-manifest.ts: the verdict memo is keyed by the asking
    router's version as well as the spec.
  • getCacheVersions() is public from @rangojs/router/cache for a custom
    store.
  • src/router/request-classification.ts: the app-switch check runs before the
    version check. Every router has its own version now, so a tab of app A
    navigating into app B always carries a version B does not have; that is an
    app switch (same reload), not a stale client.

Why here and not in each store's constructor: the cache factory builds the
store before the request context exists and without knowing the router (the
same reason CFCacheStore resolves its base URL lazily).

That makes the request context load-bearing for every deferred write, so
ctx.waitUntil() re-enters the context a task was scheduled under
(src/server/request-context.ts):

const scheduledUnder = requestContextStorage.getStore() ?? ctx;
const task = Promise.resolve().then(() =>
  requestContextStorage.run(scheduledUnder, fn),
);

cacheRoute calls store.set inside waitUntil, and so does the document
cache on a MISS. This is a hardening: these writes keep their context on
deployed workers today (cacheRoute throws without one on main, and route
caching works there). Deployed workerd lost it once, in "use cache"
revalidation. If a task ran without it, the document write would be keyed with
the whole-build pair, which no router with its own version reads. getCacheVersions() warns once
per process when it falls back to the whole-build pair in a build whose
routers have versions of their own, so a production log shows any other path
that loses the context.

Testing

setBuildVersions (src/testing/build-versions.ts, exported from
@rangojs/router/testing) installs a table where the build's is absent, so
serveShellRequest and dispatch resolve versions through the production
path. The handler cache of serveShellRequest reuses a handler only while
the router would still resolve the versions it was created with, so a second
setBuildVersions is a deploy; the production module carries nothing for
tests.

Test plan

Unit, next to the changed modules (packages/rangojs-router):

  • src/vite/__tests__/build-versions.test.ts (74): membership and
    hashing on hand-built graphs. Pins: the walk stops at registries; the
    static-importer climb; client-graph attribution; the ownership rule;
    file names tokenised (RSC and SSR); the server-reference map is not
    hashed; region paths are not hashed and the code under them is; Static
    and clientUrls() projections in the data version, Prerender in the
    document version only; the key rule (encrypting router, every router
    when unrecognised, build failure without the key file, no key when
    nothing encrypts); classifyExternal and its inputs; server
    stylesheets; portable ids (pnpm peer suffix, client-reference group of a
    virtual facade); a listed file missing on disk throws; $ in a router
    id; the files no router owns.
  • src/vite/discovery/__tests__/router-versions-phase.test.ts (31):
    the phase against files on disk. Pins: the table is written into the
    built module, in every environment that holds it, and returned; a
    relative outDir is read against the root; the build fails when a
    bundle or the version-module chunk was not recorded, when a listed file
    or the assets manifest is missing, when a router encrypts without a key
    file, and when it cannot compute or fill; the (whole build) line; a
    linked external is digested; a path external is digested; an external
    that cannot be found moves the version every build and is named; the
    encryption note is printed once and only when it applies; the report
    lists unownedFiles.
  • src/vite/discovery/__tests__/installed-packages.test.ts (11):
    name@version closure, nested and symlinked (pnpm) layouts, cycles; a
    linked package is described by its files (symlinks followed, a link
    back up the tree ends the walk, its node_modules and .git left out,
    an unreadable file throws).
  • src/vite/__tests__/stable-rsc-output.test.ts (15),
    encryption-key.test.ts (19), expose-router-id.test.ts (34: the id is
    a hash of path and call position, the same from dev-shaped and
    build-shaped code, unchanged by a comment or blank line above the call),
    expose-internal-ids.test.ts, version-plugin.test.ts,
    discovery/__tests__/virtual-module-codegen.test.ts.
  • src/server/__tests__/build-version-table.test.ts (23): resolution
    order, per-request versions, versionKeyPrefix, the rango state prefix,
    the once-per-process warning when a key is built outside a request.
  • src/cache/__tests__/background-write-versions.rsc-test.tsx (2, in
    test:unit:rsc): a route's segment record and a document-cache MISS,
    deferred with waitUntil and run outside the request's async context,
    are keyed with the serving router's data and document version.
  • src/router/__tests__/request-classification.test.ts (30): a cross-app
    navigation is an app switch whatever version the other app's tab carries.
  • src/cache/cf/__tests__/cf-cache-store-versions.test.ts (17) and
    src/cache/vercel/__tests__/vercel-cache-store-versions.test.ts
    (12): key per family, a store version wins, and the rollback
    case (an invalidation made under deploy B makes deploy A's entry stale).
  • src/rsc/__tests__/shell-build-manifest.test.ts,
    src/prerender/__tests__/build-shell-capture.test.ts: memo per version;
    build shells stamped with the document version.

Build level, real vite build (runs in test:unit, so in CI's unit-tests
job):

  • src/vite/__tests__/cache-versions-build.test.ts (22 tests over 18
    builds of a two-app host fixture, node preset, three at a time, about
    12 s). Scenarios: no change in a second directory; no change, built with
    vite build app from the parent directory; lines added above
    createRouter(); server text in A; a route added to A; a client
    component in A; a shared server module; a different encryption key; no
    bound action arguments anywhere (no key file, no key in any version, the
    build does not fail); an action only a client component imports; a new
    action in A; the host entry; code only the mounted handler reaches; a
    clientUrls() route that declares another loader; a server component's
    stylesheet; an external dependency and one of its dependencies. It also
    asserts the table is keyed by the $$ids in the built chunks and which
    files no router owns.

Userland, through @rangojs/router/testing:

  • src/testing/__tests__/build-versions.rsc-test.tsx (14, in
    test:unit:rsc): setBuildVersions + serveShellRequest on a
    VercelCacheStore over one fake handle. Pins the consumer contract:
    stored HTML follows the document version, cached data the data version,
    the reload check reads the document version, each router of a table gets
    its own pair, a consumer version wins.

E2e, production only (versions come from vite build; dev keeps one stamp,
covered by the existing HMR suites). Neither describe owns a useFixture, so
check:e2e-parity --strict expects no dev twin:

  • packages/rangojs-router/e2e/cache-version.test.ts (node preset,
    VercelCacheStore over a file-backed handle)
  • tests/cloudflare-basic/e2e/cache-version.test.ts (Cloudflare,
    CFCacheStore with KV, miniflare persistence)
  • tests/cloudflare-stress-demo/bench/retention.ts
    (pnpm bench:retention)

Both suites run runCacheVersionScenario (tests/shared-e2e): a host router
with two apps, four builds, a restart on each, a cache() route, a ppr
route and open tabs.

Red before green. Each mechanism was removed in turn (one edit, the tests that
pin it run, the edit reverted) and the result recorded: 33 ablations here and
14 more for the review fixes further down, every one red.

Removed Red First failing test Its assertion
node entry not strict 3 of 17 writes the table into the built version module and nothing else varies expected [] to have a length of 1 but got +0
registries not terminal 7 of 70 stops at the server-reference registry: one app's actions are not another's expected true to be false
walk starts at the createRouter module only 3 of 70 covers what the modules that statically import the router run expected false to be true
server component CSS not in the version 4 of 88 puts a module's stylesheets in the data version of the routers that run it expected '8fe89d0cca27462d' not to be '8fe89d0cca27462d'
external dependencies not in the version 5 of 88 puts what is installed in the data version of the routers importing it expected 'e7ee76a360c90e4a' not to be 'e7ee76a360c90e4a'
virtual region paths not portable 8 of 70 does not depend on the directory in a virtual module's region comment expected { 'router-a': […] … } to deeply equal { 'router-a': […] … }
every "use server" module counts as encrypting 2 of 70 marks a module that encrypts bound arguments, not one that only imports the runtime expected true to be undefined
assets-manifest binding not renamed 1 of 14 renames the assets-manifest import of a module plugin-rsc generated Cannot read properties of null (reading 'code')
table written with a string replacement 1 of 53 writes a router id holding $ as it is SyntaxError: Invalid or unexpected token
no client-graph attribution 5 of 70 counts a server action only a client component imports as its app's code expected true to be false
server-reference map hashed 2 of 70 does not hash the server-reference map itself tables differ
file names not normalized 12 of 70 builds the same source in another directory to the same versions tables differ
absolute $$sourceFile 14 of 50 injects the root-relative source file in a build tables differ
encryption key not in the version 2 of 70 is part of the version of a router whose code encrypts with it, and of no other expected '8fe89d0cca27462d' not to be '8fe89d0cca27462d'
document version ignores SSR output and client names 5 of 88 puts the document digest in the document version only expected 'f437b3ee551c4a8e' not to be 'f437b3ee551c4a8e'
route manifest chunk not a member 2 of 70 adds the router's own route manifest chunk, which nothing it imports reaches expected false to be true
unattributed registry targets dropped 2 of 70 gives that action to every router when there is no client graph to attribute it through expected false to be true
Prerender payload in the data version 2 of 53 puts a Prerender payload in its router's document version only expected 'c619b54a66fa67c0' to be 'b000b0df09268a30'
version module not a placeholder 3 of 20 build: a placeholder for the table, and VERSION read from it vite build failed
CF tag markers versioned 5 of 17 keys a tag marker with no version, in KV and in L1 expected [ 'v/dataA/__tag__/products' ] to deeply equal [ '__tag__/products' ]
Vercel tag markers versioned 3 of 12 keys a tag marker with no version expected [ 'v/dataA/rg:tm:products' ] to deeply equal [ 'rg:tm:products' ]
Vercel store unversioned by default 12 of 29 keys each family with its version expected [ 'rg:h:/p:shell', 'rg:i:item', … ] to deeply equal [ 'v/dataA/rg:i:item', … ]
stores key everything with the document version 7 of 29 keys each family with its version expected [ '…/v/docA/k' ] to deeply equal [ '…/v/dataA/k' ]
handler version is the data version 4 of 13 serves each router with its own versions from the table expected 'dA' to be 'hA'
state cookie prefixed with the data version 1 of 22 is prefixed with the document version, not the data version expected 'rango-state_r=data-a:…' to match /^rango-state_r=doc-a:\d+/
consumer version ignored 3 of 22 uses a consumer-set version for both, over the build table expected { data: 'data-a', document: 'doc-a' } to deeply equal { data: 'release-7', … }
build-shell memo keyed by spec only 1 of 20 judges a record per asking router's version, in either order expected null not to be null
server-reference map unsorted 1 of 14 sorts plugin-rsc's server-reference module Cannot read properties of null (reading 'code')
loader manifest unsorted 1 of 80 lists the same loaders in the same order, whatever order they registered in module text differs
client-URL projections unsorted 1 of 21 emits the projections in one order, whatever order they were recorded in module text differs
encryption key size not validated 1 of 19 rejects base64 that decodes to the wrong size expected [Function] to throw an error
build shell stamped with the data version 1 of 8 runs global + route middleware to completion (no opt-out) and stores the shell expected { buildVersion: 'test-data-version' } to deeply equal { buildVersion: 'test-version' }
testing handler cache ignores deploys 1 of 14 answers with the new deploy's version from the same router and store expected 'h1' to be 'h2'

The router-id fix, at build level: with the source-line lookup removed,
keys the table by the ids the built routers carry fails with
expected [ '1bcc02f6', '718ea8bc' ] to deeply equal [ '718ea8bc', 'c63295f3' ].

Regressions found by an adversarial review of the first implementation, each
fixed here with a test that was red first. cache-versions-build.test.ts
against the code before the fixes, 8 of 17 failing:

× builds the same source in another directory to the same versions
× keeps every data version and replaces every document version for a client change
× changes the router that encrypts with the key when the key changes, and no other
    expected [ 'encryption-key', …(3) ] to deeply equal [ 'encryption-key' ]
× covers code that only a module importing the router reaches
    expected 'c8b4bbf2c1e862e1' not to be 'c8b4bbf2c1e862e1'
× changes app A's data version when a stylesheet its server component imports changes
    expected [ 'file handler~6f1d9895.mjs', …(3) ] to include 'server-css src/apps/a/note.tsx'
× changes app B when a dependency its server code leaves external is bumped
    expected [] to deeply equal [ 'external ext-lib' ]
× follows an external dependency's own dependencies
× keeps both apps when only the host entry changes

Those are: a server component's stylesheet URL was in cached Flight and in no
version; a router's code was walked from its createRouter() module only; the
node preset's external dependencies were not hashed; every router that reached
any "use server" module counted as depending on the key; a virtual module's
region comment carried the checkout directory. A sixth showed up while writing
those tests: 1 of 10 concurrent builds of the fixture produced another version
table, because the bundler kept a different local name for the assets-manifest
import (44 of 44 identical after stableRscOutput renames it).

A second adversarial review of the fixed code found nine more. Each is fixed
with a test, and each fix removed in turn turns that test red:

Removed Red First failing test Its assertion
clientUrls() projection not in the version 2 of 44 puts a clientUrls() projection in the data version, once per module expected '9390cdc8bd8ac1e1' not to be '9390cdc8bd8ac1e1'
version check before the app-switch check 1 of 30 treats a cross-app navigation as an app switch, whatever version the other app's tab carries expected 'version-mismatch' to be 'app-switch'
linked external described by its version only 1 of 26 digests the files of a linked external dependency expected '5717bb50d8c9ebe1' not to be '5717bb50d8c9ebe1'
external that cannot be found dropped 1 of 26 gives a router a new version on every build when an external dependency cannot be found, and says which expected 'a6052b3201ae11ed' not to be 'a6052b3201ae11ed'
placeholder filled in the RSC output only 1 of 26 fills the placeholder in every environment that holds the version module expected 'var ROUTER_VERSIONS = __RANGO_ROUTER_…' to be 'var ROUTER_VERSIONS = {"router-1":[…'
portable id from the innermost node_modules 1 of 56 is root-relative inside the project and package-relative under node_modules expected 'node_modules/x/i.js' to be 'node_modules/.pnpm/x@1/node_modules/x…'
server CSS attributed by the module the entry is keyed by 1 of 56 follows the module that renders the links, not the one the entry is keyed by expected [ 'server-css src/unreached.tsx' ] to deeply equal [ 'server-css src/a/urls.tsx', … ]
any assets-manifest binding renamed 1 of 15 leaves an import under a name plugin-rsc did not generate expected 'import assetsManifest from "virtual:v…' to be undefined
same-named inputs in bundle order 1 of 56 gives two same-named inputs one order, whatever order they came in tables differ

The first of those at build level: with the projection left out,
cache-versions-build.test.ts fails changes app B's data version when a clientUrls() route declares another loader with
expected 'ba93ab92cdc2f4b3' not to be 'ba93ab92cdc2f4b3'.

One regression came from those fixes and was caught by the determinism sample,
not by a test. With "an external that cannot be found gets a per-build value"
in place, tests/cloudflare-stress-demo built to 6 different versions in 6
builds: its route manifest is large enough to ship as a workerd Text module,
and @cloudflare/vite-plugin imports that through
__CLOUDFLARE_MODULE__Text__<path>__CLOUDFLARE_MODULE__, which was read as
a package named __CLOUDFLARE_MODULE__Text__. The marker is now classified
as a file the hash already covers (classifyExternal; 6 of 6 identical
after). Pinned twice:

  • unit, in classifies a specifier (at the time: does not take another plugin's import marker for a package, red without the check with
    expected '__CLOUDFLARE_MODULE__Text__' to be undefined).
  • e2e, where CI runs it: the Cloudflare cache-version suite now builds with
    RANGO_MANIFEST_TEXT=1. Against the plugin build without the check it
    fails at an unchanged rebuild keeps every version
    (expect(built2).toEqual(built1), 4 lines differ); with it, 1 passed.

A third, independent read of that last round's diff found three more, all in
how an external or an output directory is read. Fixed, each with a test that
is red without the fix:

Removed Red First failing test Its assertion
symlinks inside a linked package skipped 1 of 11 follows a symlink to a file or a directory inside it expected '[email protected] files:44efdb69…' not to be '[email protected] files:44efdb69…'
an unreadable linked package swallowed 1 of 11 throws when one of its files cannot be read expected [Function] to throw an error
a directory entered more than once 1 of 11 ends at a link back up the tree, and skips a dangling or circular one digests differ
relative outDir read against the working directory 1 of 27 reads a relative outDir against the root, not the working directory Could not write the cache versions into the server build: no placeholder in assets/app-AAAAAAAA.js under dist/rsc

It also reported that a linked external's digest covers build artifacts in its
directory (a log, a .tsbuildinfo), so such a package can move the version
with no source change. Left as it is, on purpose: nothing says which files an
import can reach, and leaving a file out is the unsafe direction. It is in the
design doc's open questions. Checked by that read and found sound: the
clientUrls() record (no reference id or absolute path in the digest), the
package-name test, the portable ids, the fill loop, the reordered request
classification (actions, loader fetches and form posts never carry
_rsc_partial; both modes answer with the same reload), and the binding
rename.

E2e red before green, on the real fixtures:

  • Node (cache-version, production): with every store key on the document
    version, the scenario fails at app A's entry after a client change
    (Expected: "1791058306073", Received: "1791058308761"), 3 of 3 attempts.
  • Cloudflare (cache-version, production): with router ids taken from the
    transformed code, it fails at expect(a1).not.toBe(b1)
    (Expected: not "1791058354398"): both apps were served one entry.

Dogfood red before green: with a static import of the version table in
testing/serve-shell-request.ts, tests/vite-rsc-demo's test:unit:rsc
failed (Cannot find package '@rangojs/router:version'): a flight-only test
config does not alias the build-only virtual. The import is lazy again and
that suite passes. pnpm --filter @rangojs/router test:unit alone did not
show it.

Independent review. Four reviewers read the final diff of the three rounds
above. Why another round before merging: changing a hash input later moves
every consumer's versions once, so input-shape fixes belong here. Applied:

  • Run time. A task scheduled with ctx.waitUntil() re-enters the request
    context it was scheduled under, once, where every task is scheduled
    (src/server/request-context.ts). getCacheVersions() warns once per
    process when it falls back to the whole-build pair while routers have their
    own versions. The four existing per-site re-entries are left as they are
    (cache-runtime.ts, document-cache.ts STALE, loader-cache.ts,
    background-revalidation.ts): the last enters a different (render) context,
    the others now nest inside the same one and keep documenting the dependency
    at the site.
  • Build, unsafe directions. One rule, stated once in the code and in the
    design doc: an input the build cannot determine fails the build or changes
    the version every build. A listed file missing on disk, an unrecorded
    client or SSR bundle, an unrecorded version-module chunk, a missing assets
    manifest and a missing key file under an encrypting module now fail the
    build. An external import the build cannot read gets a per-build value and
    a build note; a path left external is digested.
  • Build, inputs that moved versions with no code change. Region paths are
    out of the hash input (the working directory, the pnpm peer suffix, absolute
    paths in virtual ids); the SSR output goes through the same code as the RSC
    output; a pnpm store directory is name@version in module ids; the router
    id is a hash of path and call position, so a blank line above
    createRouter() no longer renames the state cookie and the versions.
  • Structure. One ownership function (ownersOf) for registry targets,
    build data, server stylesheets and the key; one digest per file with an
    ordered reference list in place of the parts template and its per-ownership
    memo; the phase returns the table instead of parking it on the state; the
    holdsServerReferences flag, Reach.seeded, the whole-build memo and the
    test-only generation counter are gone; the report lists unownedFiles and
    what ssr-and-client is a digest of.

Two items were not applied as written, with the evidence:

  • The key rule "the runtime is in the server graph but the call pattern
    matches nothing: every router that reaches the runtime depends on the key"
    would put the key in nearly every router of an app that has server actions
    and no inline closure: plugin-rsc prepends the runtime import to every
    "use server" module (plugin-CLvIS10V.js:1709) and emits the call only
    for a bound closure (:1694), so "matches nothing" is the normal state of
    such an app, and without a stable key it would get new versions on every
    build. Implemented instead: per module, any use of the namespace the import
    binds (not the function's name); a module that cannot be read counts as
    encrypting; and the key file, which plugin-rsc writes only when a chunk
    reads the key, as a second signal: a key file with no module recognised
    makes the key every router's.
  • Typed per-family key helpers in CFCacheStore: skipped. The 20 call sites
    already pass a typed KeyVersion; a helper per family moves the same
    choice into the helper's name at every one of them.

One more input that depended on the working directory was found by building
tests/cloudflare-stress-demo from two directories, after region paths and
SSR names were fixed: plugin-rsc's client-reference group id for a chunk whose
facade is a virtual module (@cloudflare/vite-plugin's worker entry) is
path.relative(root, id) of a non-path id, which resolves against the working
directory (…/group/facade:__/__/\0virtual:cloudflare/worker-entry). The SSR
chunk holding it got another identity and the document version moved.
portableModuleId drops that prefix.

Red before green for this round (one edit each, the pinning tests run, the
edit reverted):

Item Removed First failing test Its assertion
A1 the re-entry in waitUntil keys a route's segment record with the serving router's data version Invariant: getRequestContext() called outside of a request context (no write)
A1 the same keys a document-cache MISS with the serving router's document version expected [ 'v/doc-whole/' ] to deeply equal [ 'v/doc-a/' ]
A2 the warning warns once per process when it falls back while routers have their own versions expected "warn" to be called 1 times, but got 0 times
B1 the throw for a listed file not on disk throws when a listed RSC or SSR file is not on disk expected [Function] to throw an error
B2 the throw for an unrecorded client or SSR bundle fails the build when a bundle was never recorded expected [Function] to throw error matching /did not record the client bundle/
B3 the key for every router when no module is recognised is every router's when the key file exists and no module is recognised as using it expected 'dbf75725972e6a74' not to be 'dbf75725972e6a74'
B3 the build failure without a key file fails the build when code encrypts and the key file cannot be found expected [Function] to throw an error
B3 detection by namespace use (back to the function name) does not depend on the name of the function plugin-rsc calls expected undefined to be true
B4 the per-build value for an import that cannot be read gives a package that is not installed a value unique to the build, and names it expected '2932378435ac6950' not to be '2932378435ac6950'
B4 the digest of a path external digests a file a chunk imports by path, relative to the chunk or absolute expected [] to deeply equal [ 'external lib/a.node', … ]
C4 region paths out of the hash input (build level) builds the same source from another working directory to the same versions tables differ (9 of 20 red)
C4 SSR through digestBundle (build level) the same test tables differ (1 of 20 red)
C3 the store directory reduced to name@version reduces a pnpm store directory to name@version expected 'node_modules/.pnpm/@[email protected]_react-dom…' to be 'node_modules/.pnpm/@[email protected]/…'
C5 the id from path and position (back to the line) derives the same id from the dev-shaped and the build-shaped code expected 'e0519353' to be '062f9329'
C5 the same does not change when a comment or a blank line is added above the call ids differ
D8 the list of unowned files names the server files no router owns expected [] to have a length of 1 but got +0
C5 the same (build level) keeps the router id and every version when lines are added above createRouter() expected { '93a9148a': […], … } to deeply equal { '670fb70e': […], … }
found the group id without the working directory drops the working directory from a client-reference group of a virtual facade ids differ
found a file-name token no chunk's text holds (back to ~) tells a file reference from the code around it expected '10d6b08e3e3e…' not to be '10d6b08e3e3e…'
found the name of an absolute external outside the project names a file outside the project by its path, whatever the checkout's depth expected [ 'external ../opt/lib/native.node' ] to deeply equal [ 'external /opt/lib/native.node' ]
B3 per-module detection (every module importing the runtime counts) the build-level suite vite build failed … 3 server module(s) encrypt action arguments (src/apps/b/actions.ts), but __vite_rsc_encryption_key.js is not in the RSC output directory

The last row is also the evidence for not applying the key rule as written:
with "reaches the runtime" as the test, a build with file-level actions and no
bound closure has no key file, and the missing-key failure then stops it.

A read of this round's own diff by a fresh reviewer found two more inputs
(the printable file-name token and the name of an absolute external outside
the project, both in the table above) and raised one concern that was checked
and left: an inline action whose every use is tree-shaken would be detected
before tree-shaking and then fail on the missing key file. Tried on the
fixture: a module with a bound inline action that nothing renders (imported
for its side effects only) still builds, the key file is written and its app
gets the key input, because plugin-rsc exports the hoisted action and its
server-reference map imports the module. An import the TypeScript transform
drops never reaches the graph. The build-level scenario without bound
arguments covers the direction that would fail.

Required runs, local (Apple M4, one machine). The production projects, the
cache-version suites and the retention bench ran on the final code. The dev
projects and the HMR suite ran three edits earlier; those edits are in code
that only runs in vite build (the file-name token, the name of an absolute
external, one refactor for lint):

Suite Result
Router e2e, --project=production (with its setup projects) 1149 passed, 1 passed on retry (6.1 min): the third item below
Router e2e, --project=dev 1356 passed, 1 passed on retry (10.0 min): the fourth item below
Router e2e, --project=host (RANGO_E2E_HOST=1) 9 passed
Semantic matrix (inside the two router runs) 25 passed in dev, 28 in production. This PR does not touch segment-resolution/, middleware.ts or progressive-enhancement.ts; it does touch ctx.waitUntil().
tests/cloudflare-basic, --project=production --no-deps 531 passed, 0 failed (4.8 min)
tests/cloudflare-basic, --project=dev 469 passed, 2 skipped, 1 failed (5.2 min): the first item below
test:hmr-routes-local 20 passed, 1 passed on retry (1.7 min): the second item below
Both cache-version suites (inside the two production runs) 1 passed each; the Cloudflare one builds with RANGO_MANIFEST_TEXT=1
bench:retention all rows match

The existing PPR, cache, document-cache, prerender and host-routing suites are
inside those runs, in dev and in production.

Tests that failed or needed a retry, with how often. None is touched by this
PR:

  • tests/cloudflare-basic default-prefetch-none (dev) › an offscreen viewport override waits for intersection (expect(top).toBeGreaterThan(920),
    received 0). Full dev suite on this branch, 6 runs: failed in 4 (one
    disturbed by my own source edits), 470 passed in 2. On the base commit
    (9938350d): failed in 2 of 2 full dev runs. Alone (--repeat-each=8): 8
    of 8 passed. When it fails, the production project does not run, because
    it depends on dev; that is why production above was run with --no-deps.
  • test:hmr-routes-local. Base commit, 4 runs: 21 passed in 2, recovery: helper-file fix after broken-import error re-runs discovery failed in 2
    (expect(received).not.toContain("\"blog.recovered\"") on the generated
    route names). This branch, 12 runs: that test failed in 6, with the same
    assertion; 21 passed in 3; 20 passed with one test passing on retry in 3
    (an invalid transition({ when }) edit prints the error and shows the Vite overlay twice, should not overwrite when routes have not changed once). A
    failed run reports "1 failed, 1 flaky, 19 passed" on both commits, because
    the serial retry fails in beforeAll, and leaves
    e2e/test-app/src/urls/blog.handlers.tsx modified; it was restored each
    time.
  • The router smoke setup project, first attempt of one test: 2 of 7
    production runs on this branch, both in the last round (smoke (production) › URL params: blog post once, smoke › SSR renders home page once). The
    attempt never reached the app: the fixture's server command went through
    pnpm, which ran an install whose lefthook step failed on the hooks directory
    the worktrees share (could not replace the hook: can't rename pre-commit to pre-commit.old - file already exists, then remove …/.git/hooks/pre-commit: no such file or directory), the environment gotcha in AGENTS.md. Passed on
    retry both times.
  • Router dev shell-capture-timeout (dev) › budget shorter than the settlement sequence refuses: stays MISS, no partial bake, once-per-key warning: 1 of 6 full dev runs on this branch, apiRequestContext.get: socket hang up from the test's isolated dev server on the first attempt,
    passed on retry. Alone on this branch (--repeat-each=5): 20 of 20 passed.
    Not run alone on the base commit.

One more, caused by this PR's own test and fixed: src/rsc/__tests__/stream-idle.test.ts
(real timers, a 20 ms margin) failed once in a full unit run while
cache-versions-build.test.ts ran its 14 builds at once. The builds now run
3 at a time; every full unit run and gate since has been green.

Gate, from the worktree root, on the final commit (cc7bc6aa):

  • pnpm run typecheck: 16 of 16 tasks
  • pnpm run test:unit:all: router 7409 passed (5 skipped, 6 todo) and
    461 RSC; tests/cloudflare-basic 68 and 17; tests/vite-rsc-demo 25
    and 1; e2e/mini 25 and 3; tests/shared-e2e 8
  • pnpm run lint: 0 warnings, 0 errors
  • pnpm run format
  • pnpm check:e2e-bucketing
  • pnpm check:e2e-parity --strict
  • pnpm run check:docs-api: 269 identifiers
  • pnpm check:bundle-guards: green across 3 apps
  • git status --porcelain empty; no *.gen.ts changes (no routes added
    to a committed app; the fixtures ignore their generated files)
  • On top of origin/main (eed288b1, feat(router)!: location state is versioned by the app version; createLocationState gains clearOnReload (#994) #1020); fetched again before the
    last commits, it had not moved

Commits on feat/per-app-cache-version, on top of the design-doc commit
9938350d:

  • a2389916 fix(router): a router's $$id comes from the createRouter() line in the source file
  • f17bccb7 feat(router)!: per-app cache versions, a deploy keeps the cache of code it did not change
  • d674abc4 test(e2e): cache versions across deploys on node and cloudflare; stress-demo retention check
  • b200d91c docs: per-app cache versions as built; changelog

and, from the independent review, on top of b200d91c:

  • f3a03402 fix(router): a ctx.waitUntil() task re-enters the request context it was scheduled under
  • 3760870f fix(router): a router's $$id is a hash of its file and call position, not of the call's line
  • c9bcc479 fix(router): cache versions fail closed and do not depend on where the build ran
  • cc7bc6aa docs: per-app cache versions after the independent review

Review. Three simplify passes (reuse, quality, efficiency) and an adversarial
correctness pass ran on the diff, then again on the code the fixes added, and
one more correctness read on the last round's diff.
Applied: shared resolveVersionsFrom (one function, the lookup merged into
it), versionKeyPrefix, compareStrings, id constants,
createCallStartIndices and offsetToLineColumn; the phase fails instead of
falling back to a stamp; reuse of each router's walk; a memo of located
packages. (The per-ownership digest memo of the first pass was replaced by
one digest per file in the independent review.)
Skipped, with reasons: a single request-context read per key (saves one
getStore()); a faster file-name scan than one alternation regex (it is 9 of
the stress demo's 19 ms; the alternative changes matching semantics); a
warning when plugin-rsc's server-reference module is not the expected shape
(it would only matter for minified server builds); renaming
server/build-version-table.ts.

Measurements

Full tables and conditions are in the design doc's "Measuring it". One
machine (Apple M4, 10 cores, AC power, Node 24.12, Vite 8.0.16, rolldown
1.0.3). Retention, determinism, build time and bundle guards are from
2026-10-04, on the code as it ships; the run-time numbers are from 2026-10-03.

Retention on tests/cloudflare-stress-demo (bench:retention):

Step Before After
Second request, same server hit hit
Server restarted, same build hit hit
Rebuilt with no source change miss hit
Rebuilt after a server-code change miss miss

Build time, interleaved: 7 rounds, the base commit and then the branch in each
round, one vite build per app per side, load 1.0 to 3.9:

App Before, median (range) After, median (range) Median of 7 paired differences Version step, median (range)
cloudflare-stress-demo (26k routes) 2.46 s (2.40 to 2.48) 2.46 s (2.45 to 2.47) -1 ms 23.6 ms (23.3 to 24.5)
cloudflare-multi-router (4 routers) 1.67 s (1.66 to 2.04) 1.70 s (1.69 to 1.70) +24 ms 15.0 ms (14.8 to 16.1)
cloudflare-basic 3.77 s (3.73 to 3.82) 3.81 s (3.81 to 3.83) +36 ms 22.0 ms (21.8 to 33.5)
e2e/test-app (node) 3.87 s (3.86 to 4.22) 3.89 s (3.88 to 3.91) +19 ms 28.6 ms (28.5 to 29.2)

Every median is inside its base range (the proposal's pass mark). The build is
between unchanged and 1.4% slower. The proposal projected under 5 ms for the
hashing; the step that shipped takes 15 to 29 ms, because it does more than
one sha256: it reads the RSC and the SSR output, takes region paths and file
names out of every chunk, makes module ids portable and describes the
installed externals. Earlier back-to-back sessions ran while other suites
loaded the machine (load up to 20) and are not reported.

pnpm check:bundle-guards, before and after: cloudflare-basic client router
chunk 47077 B gzip in both (limit 47104 B, same content hash in the file
name); stress-demo eager routes manifest 315 B and 314 B (limit 2048 B).

Same source, same versions, on the final code:

  • sequential builds: cloudflare-multi-router 10 of 10 identical,
    vercel-multi-router 10 of 10, stress demo 6 of 6;
  • the test fixture, 44 concurrent builds in separate directories: 44 of 44;
  • vite build in the app against vite build <app> from the repository
    root: vercel-multi-router, cloudflare-multi-router and the stress demo
    each give one table from both directories.

What the samples caught on the way, each now pinned by a test: 1 of 10 fixture
builds with another import binding name; 6 different tables in 6 stress-demo
builds while Cloudflare's Text-module marker counted as a missing package;
two tables from two working directories on vercel-multi-router (region
paths), and then on the stress demo's document version (plugin-rsc's
client-reference group id of a virtual facade).

Run time: the prefix of a key costs 62 ns when it is read from the request's
versions and 11 ns with a store-level version (2 million calls, median of 7
rounds, Node). The stress-demo throughput harness was run on both commits;
bench/compare.ts calls all 16 scenarios "within variance" with a reported
variance of 44% to 124%, so nothing is claimed from it.

Notes

Migration and surprising interactions (all in the CHANGELOG entry):

  • Entries now outlive a deploy. Anything that relied on a deploy to clear the
    cache has to invalidate tags or change version.
  • VercelCacheStore is versioned by default; the documented wiring drops the
    deployment id from getCache(). Entries written without a version are no
    longer read.
  • Two environments built from one commit have the same versions. A preview
    sharing production's KV namespace shares its entries. "use cache" item keys
    carry no host.
  • A pinned store version (new CFCacheStore({ version })) keeps its entries
    across this upgrade while the markers that invalidated them move to the
    unversioned key, so entries tagged before the last updateTag() are served
    again until their TTL. Change the pinned value once. No transitional read of
    the old marker key was added.
  • Every router's generated id changes once (the state cookie name contains the
    id): it is a hash of the file path and the call's position in the file, no
    longer of its line. The line was a bug on main twice over: build-time
    discovery and the bundle disagreed on the id of a router with a comment
    above the call, so the lazy route manifest was registered under an id no
    running router had and the trie was rebuilt at run time (measured on
    e2e/test-app, tests/cloudflare-basic and one of four routers of
    examples/cloudflare-multi-router); and a blank line above the call
    renamed the cookie. A router with an explicit id is unaffected.
  • createRouter({ version }) now takes effect on the node and vercel presets;
    the generated entry used to pass the build version over it.
  • The node and vercel RSC builds use preserveEntrySignatures: "strict".
    Shared code moves from dist/rsc/index.js into chunks.
  • A router whose build output differs on every build gets a new version on
    every build: a Prerender/Static() handler that renders a timestamp, or an
    inline action with bound arguments inside one (random IV). e2e/test-app
    and tests/cloudflare-basic do the first on purpose, so their main router
    is not stable across builds; the retention checks use fixtures and the
    stress demo.
  • A stylesheet that server code links is part of the data version. For an app
    that compiles one stylesheet from every class, a class change in a client
    component clears cached data.
  • Isolation between apps needs lazy mounts. Routers one module imports
    statically share that module's code.
  • A clientUrls() module tells the server which loaders, loading and
    transition a route has. Changing those changes the app's data version;
    changing the components it renders does not.
  • The working directory, the checkout directory and the pnpm peer layout do
    not change the versions. What remains: with build.minify: false on the
    CLIENT build, client chunks carry region comments, their names depend on the
    working directory, and the document version with them.
  • A build can now fail where it used to pass: a build output file the bundle
    lists that is not on disk, a missing plugin-rsc assets manifest, a missing
    encryption key file while code encrypts. Each of those used to leave an
    input out of the versions without a word.
  • An import the server build leaves external and cannot read (a package not
    in node_modules, a path that does not resolve, a URL) gives the routers
    importing it a new version on every build, and the build names it.
  • Every task scheduled with ctx.waitUntil() re-enters the request context it
    was scheduled under.

Follow-ups, from the independent review, not in this PR:

  • postprocessBundle hardcodes dist/rsc while the version phase reads the
    configured outDir (bundle-postprocess.ts, untouched by this branch).
  • A plugin-rsc version-range warning: the key detection, the assets manifest
    shape and the client-reference group id are all plugin-rsc 0.5.35 shapes;
    each fails closed, none warns ahead of an upgrade.
  • The remaining build-step micro-costs: the alternation regex scan over each
    chunk, the extendReach rescan of the reached set, records times routers.
  • shellMemoKey builds a Request to read a URL (already on main).

Not done, with reasons:

  • Yarn Plug'n'Play (no node_modules): not tested. On the node preset every
    external would be "not found", so versions would change on every build,
    with the build note saying so.
  • A linked workspace package left external is digested whole, build artifacts
    in its directory included. Narrowing it to what files/exports reach is
    an open question in the design doc.
  • No e2e on a deployed Vercel project or deployed Cloudflare worker. The
    Vercel preset is verified by real builds (examples/vercel-multi-router,
    10 of 10 identical) and VercelCacheStore unit tests; the node e2e runs
    VercelCacheStore over a file-backed handle.
  • A deferred task that loses the request context cannot be reproduced locally
    (Node and miniflare keep it; no loss is known on these write paths). The re-entry in
    ctx.waitUntil() is tested by producing the loss at the scheduling
    boundary. CFCacheStore's own tasks on the platform waitUntil (KV-to-L1
    promotion) are not covered by it; three of them build a key after an
    await, as they did for the key's host before this PR. The once-per-process
    warning is what would show a loss there.
  • Minified server builds (build.minify on the RSC environment): the
    server-reference map cannot be skipped and region comments do not exist, so
    adding an action in one app changes every app's data version. Not tested.
  • Encrypted bound arguments inside build-rendered payloads differ per build
    (random IV in plugin-rsc). A deterministic IV would have to come from
    plugin-rsc.
  • A dependency left external whose files change while its version does not (a
    patched package, a git dependency at a moved ref) is not seen.
  • The encryption key is resolved once per process. Two Vite configs with
    different keys loaded in one process before either builds is not supported.
  • Per-app client asset sets (so a client change in app A keeps app B's stored
    HTML) would need the client-reference map out of the shared chunk. Left as
    designed: the HTML is re-rendered from cached data.
  • tests/cloudflare-basic does not build from another working directory (its
    build-time prerender of /build-env fails before the versions are
    computed), so it is not in the two-directory sample.

ivogt added 10 commits October 3, 2026 22:31
…e source file

The id is a hash of the file path and the line of the createRouter() call.
The line was taken from the code the transform hook receives, which has
already been through the TypeScript transform: a dev server keeps comments
there and a build drops them. Build-time route discovery runs on a dev
server, so discovery and the bundle disagreed on the id of any router with a
comment above the call. The build registered that router's lazy route
manifest under an id no running router had, and the router rebuilt its trie
at run time (e2e/test-app, tests/cloudflare-basic, one of four routers in
examples/cloudflare-multi-router).

The line now comes from the file on disk (routerCallLines). A call the
source scan does not list keeps its line in the transformed code.

$$sourceFile is root-relative in a build. Discovery and the CLI read it on a
dev server, where it stays absolute; a build shipped the checkout directory
in every router's chunk.

Router ids change once for routers written with a comment above the call.
The rango state cookie name contains the id.
…de it did not change

Every build got a new version, the build time, and every persistent cache
key and stored PPR shell carried it: each deploy started cold, a rebuild of
unchanged source cleared the cache, and under a host router a deploy of one
app cleared the others.

vite build now computes two versions per createRouter() from that router's
built code. The data version (its server code) keys segment entries,
"use cache" items and loader data. The document version (the data version,
the SSR output, the client asset names, base, Prerender payloads) keys PPR
shells and document-cache responses, stamps shells, is sent as
metadata.version and is what _rsc_v is compared with.

Build (src/vite/discovery/build-versions.ts, router-versions-phase.ts):
- membership on the module graph: from the createRouter() module and its
  static importers, stopping at the build's registries; actions and loaders
  only client code imports are attributed through the client graph
- hashes the chunk bytes on disk after postprocessBundle, with chunk file
  names replaced, the server-reference map skipped and virtual-module region
  comments made root-relative
- adds what no chunk of the router holds: its lazy route manifest, its
  clientUrls() projections, the encryption key file for routers that
  encrypt, Static payloads, server-component stylesheets, and what is
  installed for dependencies the bundle left external (name@version of the
  package and its dependencies; a file digest for a linked package; a
  per-build value and a build note for one that cannot be found)
- the built @rangojs/router:version module holds a placeholder the buildApp
  post hook fills in every environment's output; a build that cannot compute
  or fill fails
- prints the versions and writes their inputs to
  node_modules/.rangojs-router-build/cache-versions.json

Determinism: no build stamp; rango({ encryptionKey }) validated at config
time; plugin-rsc's server-reference map sorted and its assets-manifest import
given one name (stable-rsc-output.ts); loader manifest and client-URL
projections sorted; node and vercel RSC entries built with
preserveEntrySignatures "strict".

Runtime: createRSCHandler resolves its router's pair once
(server/build-version-table.ts); stores read the request's versions per
operation. CFCacheStore and VercelCacheStore key cached data with the data
version and stored HTML with the document version. VercelCacheStore is
versioned by default. Tag markers are stored without a version, so an
invalidation reaches every version, including one a rollback brings back.
A consumer-set version is used for both, and is now honored on the node and
vercel presets.

classifyRequest checks for an app switch before it compares versions: each
router has its own version, so a tab of another app always carries one this
router does not have.

Testing: setBuildVersions installs the versions of a simulated build for
serveShellRequest and dispatch. getCacheVersions is public from
@rangojs/router/cache for a custom store.

Dev is unchanged: one stamp, bumped on every RSC module edit.
…ss-demo retention check

One scenario (runCacheVersionScenario, tests/shared-e2e) on a two-app host
fixture built four times with a server restart on each build: an unchanged
rebuild, a server change to app A, a client change both apps render. It
asserts cached data (a cache() route), stored HTML (a ppr route) and whether
a tab opened before the deploy reloads.

- packages/rangojs-router/e2e/cache-version.test.ts: node preset,
  VercelCacheStore over a file-backed Runtime Cache handle
- tests/cloudflare-basic/e2e/cache-version.test.ts: CFCacheStore with KV,
  miniflare persistence, route manifests built as workerd Text modules

Production only: the versions come from vite build. Neither describe owns a
useFixture, so the parity check expects no dev twin.

tests/cloudflare-stress-demo: bench:retention builds three times and fails
when an entry does not survive a restart or an unchanged rebuild, or
survives a server change.

bundle-analysis.test.ts follows the version module's new contract: a table
keyed by the built routers' ids, VERSION read from it, nothing of it in the
client bundle.
docs/design/per-app-cache-version.md is rewritten against the
implementation: how membership and hashing work, the table of version
consumers, what counts as a change, where the proposal was wrong, and the
measurements (retention, determinism samples, build time, bundle guards).

caching.md, cache-tags-flow.md and vercel-cache-store.md describe the two
versions, the unversioned tag markers and the Vercel store's default.

Skills, the docs site, the internal feature maps and the vercel-basic example
follow: what each kind of deploy keeps, how to force a clear or keep the old
behavior, rango({ encryptionKey }), setBuildVersions, getCacheVersions.

The location-state entries from #1020 say "every deploy that changes the
app": the app version they key on is the router's document version.

CHANGELOG: a Breaking entry with the deploy table, and entries for the
encryptionKey option and for tag invalidations applying across versions.
…was scheduled under

A cache store builds its key when the write runs, from the request context:
the serving router's versions and the request host. cacheRoute calls
store.set inside ctx.waitUntil(), and the document cache does the same on a
MISS. Deployed workerd runs a deferred waitUntil task outside the request's
async context (Node and miniflare keep it), so that write was keyed with the
whole-build pair, which no router with its own version reads, and cacheRoute
did not write at all: it needs the request context before it reaches the
store.

ctx.waitUntil() now runs every task inside the context it was scheduled
under: the active store, else the context itself. One place, instead of a
re-entry at each call site; the four existing ones stay.

getCacheVersions() warns once per process when it falls back to the
whole-build pair while routers have versions of their own, so a production
log shows any other path that loses the context.

The whole-build memo and the test-only generation counter are gone from
build-version-table.ts; the serveShellRequest handler cache compares the
versions a handler was created with.

Test: cache/__tests__/background-write-versions.rsc-test.tsx schedules each
task from an async scope outside any request and asserts the segment record
and the document response are keyed with the serving router's versions.
… not of the call's line

The id was sha256(path:line). Taking the line from the file as written made
build-time discovery and the bundle agree, and kept the other half of the
problem: a blank line or a comment added above createRouter() changed the
id, and with it the state cookie name, the route manifest chunk name and the
router's cache versions, for an edit that changed nothing.

Nothing else reads the line, so the id is sha256(path#ordinal): the
root-relative file and the call's position among the file's createRouter()
calls in code. The transform no longer reads the file from disk.

Every router's generated id changes once. A router with an explicit id is
unaffected.
…e build ran

One rule for what the build cannot determine: the build fails, or the version
changes on every build. A version never silently stays the same.

- a file a bundle lists that is not on disk, an unrecorded RSC, SSR or client
  bundle, an unrecorded version-module chunk and a missing plugin-rsc assets
  manifest fail the build
- the encryption key: a module depends on it when it uses the namespace it
  imports plugin-rsc's runtime under, whatever the function is called; a
  module that cannot be read counts; a key file with no module recognised
  makes the key every router's; a module that encrypts with no key file
  fails the build
- an external import is classified in one place (classifyExternal): runtime
  builtins and two plugin markers are covered elsewhere, a package by what is
  installed, a path by the file's bytes; a package that is not installed, a
  path that does not resolve and anything else get a per-build value and the
  build names them

Inputs that moved versions with no code change:

- region comments: every path is taken out of the hash input (the working
  directory, the pnpm store directory with its peer suffix, absolute paths in
  virtual ids)
- the SSR output goes through the same digest as the RSC output, file names
  tokenised
- module ids: a pnpm store directory is reduced to name@version, and
  plugin-rsc's client-reference group id of a virtual facade loses the
  working directory it carried

Structure:

- ownersOf: one ownership rule for registry targets, build data, server
  stylesheets and the key
- digestBundle: one digest per file with an ordered list of the files it
  names, in place of the parts template and its per-ownership memo
- runRouterVersionsPhase returns the table; runShellPrerenderPhase takes it
- cache-versions.json lists, once, what ssr-and-client is a digest of and
  the server files no router owns

The build-level suite gains three builds: the same source from another
working directory, lines added above createRouter(), and an app with no
bound action arguments.
The design doc describes the build as it is now: the ownership rule, what is
taken out of a chunk before hashing and why, the failure-direction table,
what an external import is, the request context in deferred writes, and the
measurements on the final code (determinism from two working directories,
interleaved build time, retention).

CHANGELOG: every router id changes once, what the build cannot read, where
and how you build, ctx.waitUntil() and the request context, the warning for
a key built outside a request.
…loyed workers

The comment, the test header and the design doc said deployed workerd runs a
deferred waitUntil task outside the request's async context. That was seen
once, in "use cache" background revalidation. It is not the general case:
cacheRoute already throws without a request context on main, and route
caching works on deployed workers.
@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@1034

commit: efc125f

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