Repository navigation
feat(router): per-app cache versions, a deploy keeps the cache of code it did not change - #1034
Merged
Merged
Conversation
…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.
commit: |
This was referenced Oct 4, 2026
Closed
…rsion # Conflicts: # packages/rangojs-router/docs/internal/feature-map.md
This was referenced Oct 4, 2026
5 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every production build got a new version, the build time
(
Date.now().toString(16)in the version plugin), and every persistent cachekey and every stored PPR shell carried it. A consumer saw three things:
the cache too: measured on
tests/cloudflare-stress-demo,/app/cached/hotwas a hit after a server restart on the same build and a miss after
rebuilding the same source;
in the worker.
What a consumer sees now
vite buildcomputes two versions for eachcreateRouter()from thatrouter's built code and prints them:
RSC data:
cache()segment entries,"use cache"values, loader data.client asset file names,
baseand the router'sPrerenderpayloads. Itkeys stored HTML (PPR shells, document-cache responses), stamps shells, is
sent as
metadata.version, and is what an open tab's_rsc_vis comparedwith.
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:
After (
pnpm --filter cloudflare-stress-demo bench:retention, this branch):The one place a consumer writes something different is the stable encryption
key, and the Vercel store wiring:
End-to-end example
A host router with two apps (Cloudflare)
Deploy a change to
apps/blog: the build prints a new pair forsrc/apps/blog/router.tsxand the same pair as before forsrc/apps/shop/router.tsx. Shop's/products/:identries and its/shellare 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)
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":
Semantics
Verified end to end on the node preset (
packages/rangojs-router/e2e/cache-version.test.ts,VercelCacheStoreover a file-backed Runtime Cache handle) and on Cloudflare(
tests/cloudflare-basic/e2e/cache-version.test.ts,CFCacheStorewith KVunder 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).
vite build <app>from another working directorybuild-versions.test.ts)createRouter()expose-router-id.test.ts)node_modules, a path that does not resolve, a URL)build-versions.test.ts,router-versions-phase.test.ts)ctx.waitUntil) run outside the request's async contextbackground-write-versions.rsc-test.tsxclientUrls()route of B declares another loaderrequest-classification.test.ts), e2e host projectRANGO_MANIFEST_TEXT=1), stress-demo benchPrerendercontent, same codebuild-versions.test.ts)Static()content, same codebuild-versions.test.ts)updateTag()under deploy B, rollback to ADeliberately unchanged:
ROUTER_VERSIONSisundefined. HMR suites unchanged.createRouter({ version }),createRSCHandler({ version })new CFCacheStore({ version }),new VercelCacheStore({ version })MemorySegmentCacheStorecreateCloudflareZonePurge, VercelexpireTagrg:{ns}:e:{tag}/ the consumer's tags).Code change
Paths are under
packages/rangojs-router/. The design, with what theproposal 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 walkstarts at the
createRouter()module and every module that staticallyimports 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 thewalk reaches (a registry target, a
Staticpayload, aclientUrls()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 thebuild 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, pluswhat its chunks leave external (
classifyExternal) and the inputs it owns.One rule for what the build cannot determine: it fails, or the version changes
on every build. It never silently stays the same.
clientUrls()module, a server component's CSSsrc/vite/discovery/router-versions-phase.ts(new) runs in thebuildApppost hook and hands the table to the build-time shell capture:
A version cannot be a constant in the code it hashes, so the built
@rangojs/router:versionmodule is byte-identical in every build and holds afree identifier the phase replaces in the written chunk
(
getVirtualBuildVersionContent,src/vite/plugins/virtual-entries.ts):The placeholder is filled in every environment's output that holds the
module, not only the RSC one.
Determinism (each one measured):
directory with its peer suffix, absolute paths in virtual ids;
portableModuleId): root-relative, the pnpm storedirectory reduced to
name@version, plugin-rsc's client-reference group idof a virtual facade without the working directory;
$$sourceFileroot-relative in a build, and the router$$ida hash of theroot-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 inconfigureEncryptionKey(
src/vite/encryption-key.ts);src/vite/plugins/stable-rsc-output.ts(new): sorts plugin-rsc'sserver-reference map and gives its assets-manifest import one local name;
(
expose-internal-ids.ts,discovery/virtual-module-codegen.ts);preserveEntrySignatures: "strict"(
RSC_ENTRY_SIGNATURES,src/vite/rango.ts), as@cloudflare/vite-pluginalready forces.
Runtime: one resolution, per-family keys
src/router-versions.tsandsrc/server/build-version-table.ts(new):createRSCHandler(src/rsc/handler.ts) resolves its router's pair once. Itsown
versionis the document version (payload metadata,_rsc_vcheck, shellstamp and gate, rango state value). The data version reaches the stores on the
request context (
_versions), read per operation:CFCacheStore:keyToRequest(key, family)/toKVKey(key, family);segments and
fn:items"data",doc:andshell2:"document", tagmarkers
null(KV key, edge marker request, isolate memo key).VercelCacheStore:FAMILY_VERSION(s,idata;r,hdocument;tmnone), now on by default.src/rsc/shell-build-manifest.ts: the verdict memo is keyed by the askingrouter's version as well as the spec.
getCacheVersions()is public from@rangojs/router/cachefor a customstore.
src/router/request-classification.ts: the app-switch check runs before theversion 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
CFCacheStoreresolves 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):cacheRoutecallsstore.setinsidewaitUntil, and so does the documentcache on a MISS. This is a hardening: these writes keep their context on
deployed workers today (
cacheRoutethrows without one onmain, and routecaching 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 onceper 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, soserveShellRequestanddispatchresolve versions through the productionpath. The handler cache of
serveShellRequestreuses a handler only whilethe router would still resolve the versions it was created with, so a second
setBuildVersionsis a deploy; the production module carries nothing fortests.
Test plan
Unit, next to the changed modules (
packages/rangojs-router):src/vite/__tests__/build-versions.test.ts(74): membership andhashing 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;
Staticand
clientUrls()projections in the data version,Prerenderin thedocument version only; the key rule (encrypting router, every router
when unrecognised, build failure without the key file, no key when
nothing encrypts);
classifyExternaland its inputs; serverstylesheets; portable ids (pnpm peer suffix, client-reference group of a
virtual facade); a listed file missing on disk throws;
$in a routerid; 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
outDiris read against the root; the build fails when abundle 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; alinked 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@versionclosure, nested and symlinked (pnpm) layouts, cycles; alinked package is described by its files (symlinks followed, a link
back up the tree ends the walk, its
node_modulesand.gitleft 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 isa 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): resolutionorder, 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, intest:unit:rsc): a route's segment record and a document-cache MISS,deferred with
waitUntiland 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-appnavigation is an app switch whatever version the other app's tab carries.
src/cache/cf/__tests__/cf-cache-store-versions.test.ts(17) andsrc/cache/vercel/__tests__/vercel-cache-store-versions.test.ts(12): key per family, a store
versionwins, and the rollbackcase (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 intest:unit, so in CI'sunit-testsjob):
src/vite/__tests__/cache-versions-build.test.ts(22 tests over 18builds 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 appfrom the parent directory; lines added abovecreateRouter(); server text in A; a route added to A; a clientcomponent 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'sstylesheet; an external dependency and one of its dependencies. It also
asserts the table is keyed by the
$$ids in the built chunks and whichfiles no router owns.
Userland, through
@rangojs/router/testing:src/testing/__tests__/build-versions.rsc-test.tsx(14, intest:unit:rsc):setBuildVersions+serveShellRequeston aVercelCacheStoreover 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
versionwins.E2e, production only (versions come from
vite build; dev keeps one stamp,covered by the existing HMR suites). Neither describe owns a
useFixture, socheck:e2e-parity --strictexpects no dev twin:packages/rangojs-router/e2e/cache-version.test.ts(node preset,VercelCacheStoreover a file-backed handle)tests/cloudflare-basic/e2e/cache-version.test.ts(Cloudflare,CFCacheStorewith KV, miniflare persistence)tests/cloudflare-stress-demo/bench/retention.ts(
pnpm bench:retention)Both suites run
runCacheVersionScenario(tests/shared-e2e): a host routerwith two apps, four builds, a restart on each, a
cache()route, apprroute 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.
expected [] to have a length of 1 but got +0expected true to be falseexpected false to be trueexpected '8fe89d0cca27462d' not to be '8fe89d0cca27462d'expected 'e7ee76a360c90e4a' not to be 'e7ee76a360c90e4a'expected { 'router-a': […] … } to deeply equal { 'router-a': […] … }expected true to be undefinedCannot read properties of null (reading 'code')$as it isSyntaxError: Invalid or unexpected tokenexpected true to be false$$sourceFileexpected '8fe89d0cca27462d' not to be '8fe89d0cca27462d'expected 'f437b3ee551c4a8e' not to be 'f437b3ee551c4a8e'expected false to be trueexpected false to be trueexpected 'c619b54a66fa67c0' to be 'b000b0df09268a30'vite build failedexpected [ 'v/dataA/__tag__/products' ] to deeply equal [ '__tag__/products' ]expected [ 'v/dataA/rg:tm:products' ] to deeply equal [ 'rg:tm:products' ]expected [ 'rg:h:/p:shell', 'rg:i:item', … ] to deeply equal [ 'v/dataA/rg:i:item', … ]expected [ '…/v/docA/k' ] to deeply equal [ '…/v/dataA/k' ]expected 'dA' to be 'hA'expected 'rango-state_r=data-a:…' to match /^rango-state_r=doc-a:\d+/versionignoredexpected { data: 'data-a', document: 'doc-a' } to deeply equal { data: 'release-7', … }expected null not to be nullCannot read properties of null (reading 'code')expected [Function] to throw an errorexpected { buildVersion: 'test-data-version' } to deeply equal { buildVersion: 'test-version' }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 carryfails withexpected [ '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.tsagainst the code before the fixes, 8 of 17 failing:
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; thenode preset's external dependencies were not hashed; every router that reached
any
"use server"module counted as depending on the key; a virtual module'sregion 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
stableRscOutputrenames 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:
clientUrls()projection not in the versionexpected '9390cdc8bd8ac1e1' not to be '9390cdc8bd8ac1e1'expected 'version-mismatch' to be 'app-switch'expected '5717bb50d8c9ebe1' not to be '5717bb50d8c9ebe1'expected 'a6052b3201ae11ed' not to be 'a6052b3201ae11ed'expected 'var ROUTER_VERSIONS = __RANGO_ROUTER_…' to be 'var ROUTER_VERSIONS = {"router-1":[…'node_modulesexpected 'node_modules/x/i.js' to be 'node_modules/.pnpm/x@1/node_modules/x…'expected [ 'server-css src/unreached.tsx' ] to deeply equal [ 'server-css src/a/urls.tsx', … ]expected 'import assetsManifest from "virtual:v…' to be undefinedThe first of those at build level: with the projection left out,
cache-versions-build.test.tsfailschanges app B's data version when a clientUrls() route declares another loaderwithexpected '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-demobuilt to 6 different versions in 6builds: 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 asa package named
__CLOUDFLARE_MODULE__Text__. The marker is now classifiedas a file the hash already covers (
classifyExternal; 6 of 6 identicalafter). Pinned twice:
classifies a specifier(at the time:does not take another plugin's import marker for a package, red without the check withexpected '__CLOUDFLARE_MODULE__Text__' to be undefined).RANGO_MANIFEST_TEXT=1. Against the plugin build without the check itfails 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:
expected '[email protected] files:44efdb69…' not to be '[email protected] files:44efdb69…'expected [Function] to throw an erroroutDirread against the working directoryCould not write the cache versions into the server build: no placeholder in assets/app-AAAAAAAA.js under dist/rscIt 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 versionwith 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), thepackage-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 bindingrename.
E2e red before green, on the real fixtures:
cache-version, production): with every store key on the documentversion, the scenario fails at
app A's entry after a client change(
Expected: "1791058306073",Received: "1791058308761"), 3 of 3 attempts.cache-version, production): with router ids taken from thetransformed 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'stest:unit:rscfailed (
Cannot find package '@rangojs/router:version'): a flight-only testconfig does not alias the build-only virtual. The import is lazy again and
that suite passes.
pnpm --filter @rangojs/router test:unitalone did notshow 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:
ctx.waitUntil()re-enters the requestcontext it was scheduled under, once, where every task is scheduled
(
src/server/request-context.ts).getCacheVersions()warns once perprocess 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.tsSTALE,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.
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.
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@versionin module ids; the routerid is a hash of path and call position, so a blank line above
createRouter()no longer renames the state cookie and the versions.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
holdsServerReferencesflag,Reach.seeded, the whole-build memo and thetest-only generation counter are gone; the report lists
unownedFilesandwhat
ssr-and-clientis a digest of.Two items were not applied as written, with the evidence:
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 onlyfor a bound closure (
:1694), so "matches nothing" is the normal state ofsuch 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.
CFCacheStore: skipped. The 20 call sitesalready pass a typed
KeyVersion; a helper per family moves the samechoice 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-demofrom two directories, after region paths andSSR 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 workingdirectory (
…/group/facade:__/__/\0virtual:cloudflare/worker-entry). The SSRchunk holding it got another identity and the document version moved.
portableModuleIddrops that prefix.Red before green for this round (one edit each, the pinning tests run, the
edit reverted):
waitUntilInvariant: getRequestContext() called outside of a request context(no write)expected [ 'v/doc-whole/' ] to deeply equal [ 'v/doc-a/' ]expected "warn" to be called 1 times, but got 0 timesexpected [Function] to throw an errorexpected [Function] to throw error matching /did not record the client bundle/expected 'dbf75725972e6a74' not to be 'dbf75725972e6a74'expected [Function] to throw an errorexpected undefined to be trueexpected '2932378435ac6950' not to be '2932378435ac6950'expected [] to deeply equal [ 'external lib/a.node', … ]digestBundle(build level)name@versionexpected 'node_modules/.pnpm/@[email protected]_react-dom…' to be 'node_modules/.pnpm/@[email protected]/…'expected 'e0519353' to be '062f9329'expected [] to have a length of 1 but got +0expected { '93a9148a': […], … } to deeply equal { '670fb70e': […], … }~)expected '10d6b08e3e3e…' not to be '10d6b08e3e3e…'expected [ 'external ../opt/lib/native.node' ] to deeply equal [ 'external /opt/lib/native.node' ]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 directoryThe 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 absoluteexternal, one refactor for lint):
--project=production(with its setup projects)--project=dev--project=host(RANGO_E2E_HOST=1)segment-resolution/,middleware.tsorprogressive-enhancement.ts; it does touchctx.waitUntil().tests/cloudflare-basic,--project=production --no-depstests/cloudflare-basic,--project=devtest:hmr-routes-localRANGO_MANIFEST_TEXT=1bench:retentionThe 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-basicdefault-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 (onedisturbed 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): 8of 8 passed. When it fails, the
productionproject does not run, becauseit 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 discoveryfailed in 2(
expect(received).not.toContain("\"blog.recovered\"")on the generatedroute 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 overlaytwice,should not overwrite when routes have not changedonce). Afailed run reports "1 failed, 1 flaky, 19 passed" on both commits, because
the serial retry fails in
beforeAll, and leavese2e/test-app/src/urls/blog.handlers.tsxmodified; it was restored eachtime.
smokesetup project, first attempt of one test: 2 of 7production runs on this branch, both in the last round (
smoke (production) › URL params: blog postonce,smoke › SSR renders home pageonce). Theattempt 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, thenremove …/.git/hooks/pre-commit: no such file or directory), the environment gotcha in AGENTS.md. Passed onretry both times.
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 upfrom 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.tsran its 14 builds at once. The builds now run3 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 taskspnpm run test:unit:all: router 7409 passed (5 skipped, 6 todo) and461 RSC;
tests/cloudflare-basic68 and 17;tests/vite-rsc-demo25and 1;
e2e/mini25 and 3;tests/shared-e2e8pnpm run lint: 0 warnings, 0 errorspnpm run formatpnpm check:e2e-bucketingpnpm check:e2e-parity --strictpnpm run check:docs-api: 269 identifierspnpm check:bundle-guards: green across 3 appsgit status --porcelainempty; no*.gen.tschanges (no routes addedto a committed app; the fixtures ignore their generated files)
origin/main(eed288b1, feat(router)!: location state is versioned by the app version; createLocationState gains clearOnReload (#994) #1020); fetched again before thelast commits, it had not moved
Commits on
feat/per-app-cache-version, on top of the design-doc commit9938350d:a2389916fix(router): a router's $$id comes from the createRouter() line in the source filef17bccb7feat(router)!: per-app cache versions, a deploy keeps the cache of code it did not changed674abc4test(e2e): cache versions across deploys on node and cloudflare; stress-demo retention checkb200d91cdocs: per-app cache versions as built; changelogand, from the independent review, on top of
b200d91c:f3a03402fix(router): a ctx.waitUntil() task re-enters the request context it was scheduled under3760870ffix(router): a router's $$id is a hash of its file and call position, not of the call's linec9bcc479fix(router): cache versions fail closed and do not depend on where the build rancc7bc6aadocs: per-app cache versions after the independent reviewReview. 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 intoit),
versionKeyPrefix,compareStrings, id constants,createCallStartIndicesandoffsetToLineColumn; the phase fails instead offalling 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 ofthe 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):Build time, interleaved: 7 rounds, the base commit and then the branch in each
round, one
vite buildper app per side, load 1.0 to 3.9:cloudflare-stress-demo(26k routes)cloudflare-multi-router(4 routers)cloudflare-basice2e/test-app(node)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-basicclient routerchunk 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:
cloudflare-multi-router10 of 10 identical,vercel-multi-router10 of 10, stress demo 6 of 6;vite buildin the app againstvite build <app>from the repositoryroot:
vercel-multi-router,cloudflare-multi-routerand the stress demoeach 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(regionpaths), 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.tscalls all 16 scenarios "within variance" with a reportedvariance of 44% to 124%, so nothing is claimed from it.
Notes
Migration and surprising interactions (all in the CHANGELOG entry):
cache has to invalidate tags or change
version.VercelCacheStoreis versioned by default; the documented wiring drops thedeployment id from
getCache(). Entries written without a version are nolonger read.
sharing production's KV namespace shares its entries.
"use cache"item keyscarry no host.
new CFCacheStore({ version })) keeps its entriesacross this upgrade while the markers that invalidated them move to the
unversioned key, so entries tagged before the last
updateTag()are servedagain until their TTL. Change the pinned value once. No transitional read of
the old marker key was added.
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
maintwice over: build-timediscovery 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-basicand one of four routers ofexamples/cloudflare-multi-router); and a blank line above the callrenamed the cookie. A router with an explicit
idis unaffected.createRouter({ version })now takes effect on the node and vercel presets;the generated entry used to pass the build version over it.
preserveEntrySignatures: "strict".Shared code moves from
dist/rsc/index.jsinto chunks.every build: a
Prerender/Static()handler that renders a timestamp, or aninline action with bound arguments inside one (random IV).
e2e/test-appand
tests/cloudflare-basicdo the first on purpose, so their main routeris not stable across builds; the retention checks use fixtures and the
stress demo.
that compiles one stylesheet from every class, a class change in a client
component clears cached data.
statically share that module's code.
clientUrls()module tells the server which loaders,loadingandtransition a route has. Changing those changes the app's data version;
changing the components it renders does not.
not change the versions. What remains: with
build.minify: falseon theCLIENT build, client chunks carry region comments, their names depend on the
working directory, and the document version with them.
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.
in
node_modules, a path that does not resolve, a URL) gives the routersimporting it a new version on every build, and the build names it.
ctx.waitUntil()re-enters the request context itwas scheduled under.
Follow-ups, from the independent review, not in this PR:
postprocessBundlehardcodesdist/rscwhile the version phase reads theconfigured
outDir(bundle-postprocess.ts, untouched by this branch).shape and the client-reference group id are all plugin-rsc 0.5.35 shapes;
each fails closed, none warns ahead of an upgrade.
chunk, the
extendReachrescan of the reached set, records times routers.shellMemoKeybuilds aRequestto read a URL (already onmain).Not done, with reasons:
node_modules): not tested. On the node preset everyexternal would be "not found", so versions would change on every build,
with the build note saying so.
in its directory included. Narrowing it to what
files/exportsreach isan open question in the design doc.
Vercel preset is verified by real builds (
examples/vercel-multi-router,10 of 10 identical) and
VercelCacheStoreunit tests; the node e2e runsVercelCacheStoreover a file-backed handle.(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 schedulingboundary.
CFCacheStore's own tasks on the platformwaitUntil(KV-to-L1promotion) 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-processwarning is what would show a loss there.
build.minifyon the RSC environment): theserver-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.
(random IV in plugin-rsc). A deterministic IV would have to come from
plugin-rsc.
patched package, a git dependency at a moved ref) is not seen.
different keys loaded in one process before either builds is not supported.
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-basicdoes not build from another working directory (itsbuild-time prerender of
/build-envfails before the versions arecomputed), so it is not in the two-directory sample.