Skip to content

feat(router)!: location state is versioned by the app version; createLocationState gains clearOnReload (#994) - #1020

Merged
ivogt merged 13 commits into
mainfrom
fix/994-location-state-version
Oct 3, 2026
Merged

ivogt merged 13 commits into
mainfrom
fix/994-location-state-version

Conversation

@ivogt

@ivogt ivogt commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Location state written by another build of the app now reads as undefined,
with nothing to configure. createLocationState gains one option,
clearOnReload: state the server did not render with is dropped when the
entry's document is loaded.

Breaking: location state no longer survives a deploy. See the changelog entry
"location state written by another app version reads as no state".

Problem

A history entry outlives a deploy. A tab left open across a release keeps its
history.state through reloads and back/forward, and a location state key
(file path plus export name) is the same in every deploy. useLocationState
and Def.read() returned the stored value with an unchecked cast, so after a
release that changed a state's shape the new code received the old value typed
as the new one (#994): grid.rows.map(...) on a value that has no rows.

Separately, the server renders a document without history.state, so a reader
hydrates as undefined and applies the stored value right after. For state
that decides how much content is on the page (a "load more" list carrying the
earlier pages on its link), a refresh renders the server's 50 products and then
inserts 250 above them.

Before / after

// Before and after: the definition does not change.
export const GridState = createLocationState<GridSnapshot>();
// Before: after a deploy that changed GridSnapshot, an open tab's refresh or
// back/forward hands the reader the older build's object.
const grid = useLocationState(GridState); // old shape, typed as GridSnapshot

// After: state another build wrote is not read.
const grid = useLocationState(GridState); // undefined, as with no state
// New option
export const CarriedProducts = createLocationState<Product[]>({
  clearOnReload: true, // not applied after a document load of the entry
});

End-to-end consumer example: the load-more page

// location-states.ts
import { createLocationState } from "@rangojs/router";

export const CarriedProducts = createLocationState<Product[]>({
  clearOnReload: true,
});
"use client";
import { Link, useLoader, useLocationState } from "@rangojs/router/client";
import { ProductsLoader } from "./loaders"; // the page named by ?page
import { CarriedProducts } from "./location-states";

export function ProductList() {
  const { data } = useLoader(ProductsLoader);
  const carried = useLocationState(CarriedProducts) ?? [];
  const carriedIds = new Set(carried.map((product) => product.id));
  const products = [
    ...carried,
    ...data.products.filter((product) => !carriedIds.has(product.id)),
  ];

  return (
    <>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
      <Link
        to={`/products?page=${data.page + 1}`}
        state={[CarriedProducts(products)]}
        scroll={false}
      >
        Load more
      </Link>
    </>
  );
}

"Load more" on page 5 goes to ?page=6 showing 250 + 50. A refresh of
?page=6 shows the 50 the server rendered and nothing is inserted after
hydration. After a deploy, back/forward to that entry also shows 50: the
carried products were written by the older build. The carriedIds filter is a
workaround for a router ordering defect (see Notes), not part of the pattern.

Semantics

The app version of location state

Every history entry records the app version its location state was written
under, in one field next to the slots (__rsc_lsv). A read returns only state
recorded under the version the document was loaded with. It applies to typed
slots, plain state (<Link state={{ ... }}> read with useLocationState()),
flash slots and state the server sets.

The entry's location state typed slot plain state flash slot clearOnReload slot
reload under the same version the value the value shown once if not yet consumed undefined, slot removed
document load under another version undefined undefined undefined, not consumed undefined, slot removed
back/forward (popstate) to an entry another version wrote undefined undefined undefined, not consumed undefined
entry written by a release before this change undefined undefined undefined undefined
a release before this change reads a new entry the value the value the value not read (~r key)
dev: an RSC edit bumps the version in a running page the value the value the value the value
  • A write under the running version into an entry another version wrote first
    drops that entry's location state, so the new record cannot make the old
    state readable again. idx, the scroll key and the intercept fields stay.
  • The version is the one the client already tracks for the _rsc_v reload
    check, as it was when the document loaded. In dev an RSC edit moves the live
    version inside a running page; the page keeps its state and the next
    document load drops it, which is what a deploy does.
  • The default version is generated per build, so this happens on every
    deploy. An app that pins its own app version keeps location state for as
    long as that value stays the same.

Why a record on the entry, not a version in each key

The key is injected at build time and also written by the server; the version
is a runtime value. Keys that change per deploy would leave a dead slot per
deploy in every entry and change Def.__rsc_ls_key (the testing surface) on
every build, and plain state has no key to version. One field per entry covers
both kinds, leaves no dead keys, and a release before this change ignores it.

clearOnReload

What happens to the entry A clearOnReload slot
The reader mounts during a client navigation applied
back/forward inside the running app, a server action's state applied
Refresh, or a back/forward that loads the document not applied; removed from history.state
Later on that page: a reader mounts, or a navigation returns to the entry still undefined until the next write
The page is restored from the back/forward cache untouched

Stored under <key>~r, value as-is. At client start-up, before hydration, the
router removes every ~r key from the entry, by key alone. flash with
clearOnReload throws in development.

Code-change summary

Options type and public surface:

export interface LocationStateOptions {
  flash?: boolean;
  clearOnReload?: boolean;
}

No new export: the barrels (src/index.ts, src/index.rsc.ts,
src/client.tsx, src/client.rsc.tsx) are unchanged against main.

src/browser/react/location-state-shared.ts owns the mechanism:

const LOCATION_STATE_VERSION_KEY = "__rsc_lsv";
let documentVersion: string | undefined;

export function setLocationStateVersion(version: string | undefined): void;
// The state of a `{ state }` snapshot (default: the current entry) when it
// was recorded under the client's version.
export function readableLocationState(location?): Record<string, unknown> | undefined;
// Stamp a state object that carries location state.
export function stampLocationState(state: Record<string, unknown>): void;
// entryState + additions under the client's version; drops what another
// version left there first.
export function addLocationState(entryState: unknown, additions?): Record<string, unknown>;
  • Writers: buildHistoryState (stamps when the result has location state),
    mergeLocationState and Def.write() (through addLocationState).
  • Readers: Def.read() / Def.read(location) and useLocationState, typed
    and plain (through readableLocationState). The hook differs from main by
    that one read.
  • src/browser/history-state.ts: clearLocationStateOnDocumentLoad() removes
    the ~r slots; initBrowserApp calls it before its first await.
  • src/browser/rsc-router.tsx: initBrowserApp calls
    setLocationStateVersion(metadata.version) once the payload is decoded. The
    HMR path (bridge.updateVersion) does not.
  • src/browser/partial-update.ts: the to.state a transition({ when })
    predicate sees for a traversal is built with addLocationState, so it is
    what the commit leaves on the entry.
  • src/testing/render-route.tsx resets the version (a renderRoute tree has
    none, so seeds are written without a record and read back) and, with
    hydrate: true, runs the start-up removal of ~r slots.
  • src/testing/location-state-key.ts rejects a name that contains ~.
Router chunk, cloudflare-basic, gzip Size
main 46891B
this PR 47077B
ratchet 47104B

Test plan

Unit and hook (packages/rangojs-router):

  • src/__tests__/location-state-version.test.ts: state written under one
    version reads undefined after a document load under another, for
    Def.write(), a navigation and server-set state; an entry with no
    record reads undefined; a write under another version does not bring
    the older state back and keeps bookkeeping; only entries with location
    state are stamped; a client without a version writes no record; an HMR
    version bump keeps the state and the next document load drops it.
  • src/testing/__tests__/location-state-version.test.tsx: the hook for
    typed, flash and plain readers after a document load under the same and
    another version (no recoverable hydration error), on popstate to an
    older-version entry, a flash written into an older entry, an HMR bump;
    renderRoute seeds read back hydrated or not, and an entry a versioned
    client wrote is not read.
  • src/__tests__/location-state-version-surface.test.tsx and
    .rsc-test.tsx: no export of @rangojs/router, /client, /browser,
    /testing, /testing/dom, /testing/flight (both conditions) is named
    after a version or holds the running one; a definition, an entry and the
    hook result carry none.
  • location-state-shared.test.ts (the storage key per option, the two
    keys never read each other, flash with clearOnReload throws),
    history-state.test.ts and init-browser-app-location-state.test.ts
    (what start-up removes, and when), location-state-key.test.ts,
    location-state-types.test.ts.

Userland, through the public primitives:

  • src/testing/__tests__/render-route-location-state-options.test.tsx:
    clearOnReload on a client mount, on a document load, with a late
    Suspense reader, with a loader redirect's state.
  • location-state-options-server.test.ts and
    render-handler.rsc-test.tsx: runMiddleware, runLoaderResult,
    runInRequestContext and renderHandler return
    { [Def.__rsc_ls_key]: value } and nothing else.
  • tests/cloudflare-basic/test/location-state-options.test.tsx: the same
    against the app's own components; seeds read back without a version, an
    entry another version wrote is not read.

E2e, dev and (production), test-app and cloudflare-basic
(location-state.options):

  • state another app version stored reads as no state after a reload, its
    own is kept (typed and plain; another version and no record).
  • back/forward to an entry another app version wrote reads no state.
  • clearOnReload: carried by a client navigation and dropped by a reload;
    dropped by a back/forward that loads the document.
  • dev only, local: loader-hmr "an RSC edit keeps the page's location
    state; the next document load drops it".

Red before green:

  • Unit and hook, before the implementation: 11 failed, 4 passed of 15.
  • E2e (dev, test-app) on the previous runtime: the two version tests fail
    (Expected "none", Received "desc:3").
  • HMR, with the comparison switched to the live version: both HMR unit cases
    and the loader-hmr case fail (Expected "desc:3", Received "none").
  • Public surface, with a version getter exported from @rangojs/router: both
    checks fail.

Gates: pnpm run typecheck (16/16), pnpm run test:unit:all (router 7168 +
445 rsc, cloudflare-basic 68 + 17), pnpm run lint, pnpm run format,
pnpm check:e2e-bucketing, pnpm check:e2e-parity --strict,
pnpm run check:docs-api, pnpm check:bundle-guards (47077B). E2e
location-state: test-app dev 42, production 33; cloudflare-basic 17 and 17.

Notes

  • What createLocationState: option to drop state written with an older shape (version / validate) #994 asked for, and what this PR does instead. The issue proposed
    two createLocationState options: version (a number the app bumps when a
    state's shape changes) and validate (a guard run on every read). This PR
    ships neither. A per-definition version protects only the definitions whose
    author remembered to bump it, and forgetting is silent: it is the original
    bug again. The router already has a version that changes with every build,
    so it can make the decision for every definition and for plain state, with
    nothing to adopt and nothing to forget. With state from other builds gone,
    what is left for validate is state the same build wrote in a shape its own
    type forbids, which is an app bug rather than a deploy hazard, and not worth
    an option and a check on every read. The cost of the implicit rule is that
    location state no longer survives a deploy even when its shape did not
    change.
  • Migration. Nothing to change in app code. On the first release with
    this, every entry written before the upgrade counts as another version, so
    open tabs lose their location state once; after that, at every deploy.
    State that has to outlive a deploy belongs in the URL, a cookie or storage.
  • Rollback. A release before this change ignores the record and reads
    values as it always did.
  • Where the version is already visible (unchanged by this PR): the
    version option of createRouter() and of the handler, router.version,
    the @rangojs/router:version virtual module, and the result of
    initBrowserApp() from @rangojs/router/browser (version,
    bridge.getVersion()). The record itself is readable in
    window.history.state, and therefore in the from.state / to.state of a
    transition({ when }) predicate, which is documented as the raw
    history.state.
  • Dev. A refresh after a server-module edit, or after a dev-server
    restart, drops location state, like a deploy.
  • Not swept. State another version wrote stays in its entry, unread,
    until a write claims the entry or the browser drops it.
  • Router ordering defect, not fixed here. A navigation applies the
    destination entry's location state to a mounted reader as soon as the entry
    is pushed (navigation-transaction.ts dispatches __rsc_locationstate in
    commit), before the destination's tree commits (partial-update.ts). The
    load-more example filters duplicates to work around it.

Review history (2026-10-03)

The branch went through four designs before this one; they are in its commit history and disappear in the squash.

  1. Explicit version: number with a stored envelope. A four-angle review found it unsafe on rollback and a cost on every read for definitions without options.
  2. version and clearOnReload in the slot key, value stored raw.
  3. The maintainer's direction: the app author should not manage a version, and the router version is not a userland API. version was removed and location state became versioned by the router version, one stamp per history entry.
  4. validate was dropped as well: with implicit versioning it had little left to do.

To be filed separately: on a client navigation a mounted useLocationState reader receives the destination entry's state before the destination tree commits, so a load-more list held on a loader shows the new state next to the old page's items until the loader lands. The skill documents filtering as a workaround.

@ivogt
ivogt force-pushed the fix/994-location-state-version branch from 75a3a88 to 211299f Compare October 2, 2026 06:16
@ivogt
ivogt force-pushed the fix/992-location-state-late-hydration branch from 456ed18 to fcc44a0 Compare October 3, 2026 13:08
Base automatically changed from fix/992-location-state-late-hydration to main October 3, 2026 13:54
ivogt added 9 commits October 3, 2026 16:59
The slot key is the file path and export name, so it survives a deploy
that changes the value's shape. A tab that stays open then restores
the old value typed as the new one. version stores { v, value } and a
missing or different v reads as undefined. validate checks the value
on read and a failure reads as undefined too.

Closes #994.
vi.fn of a type predicate is not LocationStateValidate. renderRoute
now stores the definition's slot, so a versioned seed is { v, value }.
The hook keeps the first client read until popstate or
__rsc_locationstate, same as the hydration fix.
…y cannot trust

The version envelope is now marked with __rsc_ls_env, a key in the prefix
the router reserves in history.state, so a raw user value shaped
{ v, value } is no longer read by a versioned definition. A definition
without version reads undefined for a versioned slot instead of the
envelope typed as the state.

A validate that throws reads undefined. It used to throw out of render
and out of the popstate and __rsc_locationstate listeners, where the
reader kept the previous entry's value. The error is logged once per
definition outside production. An empty slot no longer calls validate.

LocationStateValidate is renamed LocationStateGuard: it sat next to the
unrelated compile-time type ValidateLocationState.
…ment load

The server renders a document without history.state, so state applied
after hydration changes content already on screen: a load-more list
grows above the page the server rendered on every refresh.

A definition with clearOnReload stores its value in the marked envelope
with the option recorded. initBrowserApp removes those slots from the
entry it starts on, in its synchronous prefix, before the payload is
decoded or anything hydrates. Start-up has only history.state to go by,
which is why the slot records the option. Client mounts, popstate and
server-set state in the running app are unchanged; a document response
carries no location state, so none can be dropped.

renderRoute writes seeds through the definition, and hydrate mode calls
the same clearing function, so a test models a document load without a
copy of start-up.

flash with clearOnReload is rejected by the type and, outside
production, by a throw. The version/validate decoder moves into
createLocationState so the development-only reporting folds out of a
production build.
…ptions

Both apps get a load-more page (clearOnReload carried items, a slot
without options, and state the handler sets on every request) and a
page with a versioned and a validated reader. The bodies live in
tests/shared-e2e and run in dev and production:

- carried state is shown after a client navigation, dropped by a reload
  and by a back/forward that loads the document, and carried again by
  the next navigation; a document response delivers no server-set state
- a slot another deploy stored (older version, pre-version raw value,
  a { v, value } look-alike, a value validate rejects or throws on)
  reads as no state after a reload

cloudflare-basic also tests its real components through the published
testing entries.
… and changelog

state.md describes every option, with the load-more example for
clearOnReload and the rollback note: a release older than the options
reads an envelope as the state. The testing pages say that a renderRoute
seed takes the value, that hydrate mode is a document load, and that the
server primitives' locationState holds the stored slot, so a versioned
definition is asserted with Def.read({ state: locationState }).

The internal maps name the owning modules and the start-up order that
keeps server-set state out of the clearing's reach.
… in the slot key

The envelope { __rsc_ls_env, v, clearOnReload, value } is replaced by a
suffix on the storage key; the value is always stored raw:

  <key>          no version, no clearOnReload (as before the options)
  <key>~v<n>     version
  <key>~r        clearOnReload
  <key>~v<n>~r   both

Slots outlive deploys, so a stored format has to be understood by every
release that ever reads the slot. With the key there is no format: a
release without the options (a rollback included) and a definition
without them read the plain key and never see a suffixed slot, another
version reads another key, and a definition without options runs the
read and write it ran before. Start-up removes clearOnReload slots by
key alone, so a slot stored before the option was adopted is neither
read nor swept, and leftovers are swept after it is removed.

An injected key ends in #<ExportName> (identifier exports only) and ~ is
not an identifier character, so no file or export name can produce a
suffix. withLocationStateKey rejects ~ in a name. Writers do not delete
sibling versions: the previous version's slot stays in its entries until
they are replaced.

validate wraps read() only when set. LocationStateOptions is one
interface again; flash with clearOnReload is rejected by the
development-only throw. The prefix literal is one exported constant with
an isLocationStateKey helper, used in history-state.ts and the testing
key helper.
…tate keys

The shared bodies assert the keys a real build produces
(<ExportName>~r, <ExportName>~v2) with raw values, share their opening
steps, and use hoisted byTestId / markDocument helpers together with the
bodies that already had their own copies. The stale-state body keeps the
cases a browser adds over the unit table: another version's key, the
plain key from before the version, and a validate that throws.

The load-more fixture's duplicate filter is described as a workaround
for the router applying a destination entry's state before its tree
commits.
state.md, the changelog and the internal maps describe the derived key
(<key>~v<n>, <key>~r), what a version bump leaves in history.state, and
what adding, removing or rolling back an option reads. A rollback is
safe: no release reads a format it does not know. The testing pages
drop the Def.read({ state }) advice, since locationState is
{ [Def.__rsc_ls_key]: value } for every definition again.

The duplicate filter in the load-more example is described as a
workaround for a known router ordering, not as the contract.
@ivogt
ivogt force-pushed the fix/994-location-state-version branch from 211299f to 5cdc86a Compare October 3, 2026 15:44
@ivogt ivogt changed the title feat(router): createLocationState can reject an older stored shape (#994) feat(router): createLocationState options: version, validate, clearOnReload (#994) Oct 3, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 1bc1889

ivogt added 3 commits October 3, 2026 20:52
…rsion option (#994)

Location state another app version wrote now reads as undefined, for typed
slots and plain state alike. The `version` option of createLocationState and
its `~v<n>` key suffix are removed: the app author no longer manages a version,
and none is exposed.

Mechanism: one stamp per history entry (`__rsc_lsv`), written wherever the
router writes location state (buildHistoryState, mergeLocationState,
Def.write) and compared on read (Def.read, the hook's plain read) with the
version the document loaded with. An entry without a stamp counts as another
version. A write into an entry another version wrote drops that entry's
location state first, keeping the router's bookkeeping fields.

The comparison uses the version met at document load (setLocationStateVersion,
called by initBrowserApp), not getAppVersion(): a dev HMR bump moves the live
version inside a running page and must not drop that page's state.

A renderRoute tree has no version, so seeds are written without a stamp and
the server primitives' locationState is unchanged.

Router chunk (cloudflare-basic, gzip): 47048B -> 47074B. To stay under the
47104B ratchet the validate check moved into read, the flash clear in the hook
goes through Def.delete(), and the start-up sweep tests the `~r` suffix inline.
…on state (#994)

Both apps, dev and production, through shared bodies in tests/shared-e2e:

- state another app version stored reads as no state after a reload, typed
  and plain, with a clean hydration; a reload under the same version keeps it.
  "Another build is running" is modelled by rewriting the version the entry
  records (or removing it, an entry from before the record existed).
- back/forward to an entry another version wrote reads no state, without a
  document load.
- a slot whose value validate rejects reads as no state (kept apart from the
  version cases so it goes with the option if that is removed).

The grid fixture now has a typed reader, a plain-state reader and a validated
one; its `VersionedGrid` definition is `GridState`, without options.

Dev only, in the local loader-hmr suite: an RSC edit bumps the app version in
the running page, the page keeps its location state, and the next document
load drops it.

cloudflare-basic's dogfood test covers the same through renderRoute and
runMiddleware: seeds read back without a version, and a middleware's
locationState carries none.
…sion option is gone (#994)

Changelog: a Breaking entry for the implicit version (what is dropped, when, and that there is nothing to configure), validate as its own Added entry, and the clearOnReload entry without the version option. The hooks and testing skill pages, docs/testing.md and the internal feature maps drop the option and describe the per-entry record and why the comparison uses the version met at document load.
@ivogt ivogt changed the title feat(router): createLocationState options: version, validate, clearOnReload (#994) feat(router)!: location state is versioned by the app version; createLocationState gains clearOnReload and validate (#994) Oct 3, 2026
createLocationState's options are now `{ flash?, clearOnReload? }`. State
another build wrote is already dropped by the app version every entry
records, so nothing ships that the app author has to add or maintain.

Removed: the `validate` option, its catch and development-only report, the
`LocationStateGuard` type and its export from the four barrels, and the
`TState` parameter on `LocationStateOptions`, which existed only for it. The
barrels are now identical to main.

The hook is back to main's form except for the read, which goes through
`readableLocationState()`: the flash clear no longer calls `Def.delete()` and
the key-based `readLocationStateValue` is restored. `readableLocationState`
returns objects only, and the start-up sweep has its object guard again; both
had been loosened to save bytes for `validate`.

Tests, fixtures and docs that existed only for the option are gone: its unit,
hook and renderRoute cases, the dogfood case, the `ValidatedGrid` definition
and reader in both apps, and the shared e2e body with its case in both suites.
The fixture route is renamed to what it now covers
(`/location-state/app-version`, `/location-state-app-version`), with
`router.named-routes.gen.ts` regenerated in both apps.

Changelog: the Unreleased entries are the Breaking entry for the app version
and the Added entry for `clearOnReload`.

Router chunk (cloudflare-basic, gzip): 47074B -> 47077B; 46891B on main.
@ivogt ivogt changed the title feat(router)!: location state is versioned by the app version; createLocationState gains clearOnReload and validate (#994) feat(router)!: location state is versioned by the app version; createLocationState gains clearOnReload (#994) Oct 3, 2026
@ivogt
ivogt merged commit eed288b into main Oct 3, 2026
43 checks passed
@ivogt
ivogt deleted the fix/994-location-state-version branch October 3, 2026 18:49
ivogt added a commit that referenced this pull request Oct 4, 2026
…de it did not change (#1034)

* docs(design): per-app cache versions, releasing without clearing the cache

* fix(router): a router's $$id comes from the createRouter() line in the 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.

* feat(router)!: per-app cache versions, a deploy keeps the cache of code 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.

* test(e2e): cache versions across deploys on node and cloudflare; stress-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: per-app cache versions as built; changelog

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.

* fix(router): a ctx.waitUntil() task re-enters the request context it 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.

* fix(router): a router's $$id is a hash of its file and call position, 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.

* fix(router): cache versions fail closed and do not depend on where the 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.

* docs: per-app cache versions after the independent review

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.

* docs: the waitUntil re-entry is a hardening; say what was seen on deployed 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.
@ivogt ivogt mentioned this pull request Oct 4, 2026
2 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant