Repository navigation
feat(router)!: location state is versioned by the app version; createLocationState gains clearOnReload (#994) - #1020
Merged
Merged
Conversation
ivogt
force-pushed
the
fix/994-location-state-version
branch
from
October 2, 2026 06:16
75a3a88 to
211299f
Compare
ivogt
force-pushed
the
fix/992-location-state-late-hydration
branch
from
October 3, 2026 13:08
456ed18 to
fcc44a0
Compare
2 of 7 tasks
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
force-pushed
the
fix/994-location-state-version
branch
from
October 3, 2026 15:44
211299f to
5cdc86a
Compare
commit: |
…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.
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
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.
2 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.
Location state written by another build of the app now reads as
undefined,with nothing to configure.
createLocationStategains one option,clearOnReload: state the server did not render with is dropped when theentry'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.statethrough reloads and back/forward, and a location state key(file path plus export name) is the same in every deploy.
useLocationStateand
Def.read()returned the stored value with an unchecked cast, so after arelease 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 norows.Separately, the server renders a document without
history.state, so a readerhydrates as
undefinedand applies the stored value right after. For statethat 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
End-to-end consumer example: the load-more page
"Load more" on page 5 goes to
?page=6showing 250 + 50. A refresh of?page=6shows the 50 the server rendered and nothing is inserted afterhydration. After a deploy, back/forward to that entry also shows 50: the
carried products were written by the older build. The
carriedIdsfilter is aworkaround 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 staterecorded under the version the document was loaded with. It applies to typed
slots, plain state (
<Link state={{ ... }}>read withuseLocationState()),flashslots and state the server sets.flashslotclearOnReloadslotundefined, slot removedundefinedundefinedundefined, not consumedundefined, slot removedundefinedundefinedundefined, not consumedundefinedundefinedundefinedundefinedundefined~rkey)drops that entry's location state, so the new record cannot make the old
state readable again.
idx, the scrollkeyand the intercept fields stay._rsc_vreloadcheck, 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.
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) onevery 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
clearOnReloadslothistory.stateundefineduntil the next writeStored under
<key>~r, value as-is. At client start-up, before hydration, therouter removes every
~rkey from the entry, by key alone.flashwithclearOnReloadthrows in development.Code-change summary
Options type and public surface:
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.tsowns the mechanism:buildHistoryState(stamps when the result has location state),mergeLocationStateandDef.write()(throughaddLocationState).Def.read()/Def.read(location)anduseLocationState, typedand plain (through
readableLocationState). The hook differs from main bythat one read.
src/browser/history-state.ts:clearLocationStateOnDocumentLoad()removesthe
~rslots;initBrowserAppcalls it before its first await.src/browser/rsc-router.tsx:initBrowserAppcallssetLocationStateVersion(metadata.version)once the payload is decoded. TheHMR path (
bridge.updateVersion) does not.src/browser/partial-update.ts: theto.stateatransition({ when })predicate sees for a traversal is built with
addLocationState, so it iswhat the commit leaves on the entry.
src/testing/render-route.tsxresets the version (arenderRoutetree hasnone, so seeds are written without a record and read back) and, with
hydrate: true, runs the start-up removal of~rslots.src/testing/location-state-key.tsrejects a name that contains~.Test plan
Unit and hook (
packages/rangojs-router):src/__tests__/location-state-version.test.ts: state written under oneversion reads
undefinedafter a document load under another, forDef.write(), a navigation and server-set state; an entry with norecord reads
undefined; a write under another version does not bringthe 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 fortyped, 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;
renderRouteseeds read back hydrated or not, and an entry a versionedclient wrote is not read.
src/__tests__/location-state-version-surface.test.tsxand.rsc-test.tsx: no export of@rangojs/router,/client,/browser,/testing,/testing/dom,/testing/flight(both conditions) is namedafter 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 twokeys never read each other,
flashwithclearOnReloadthrows),history-state.test.tsandinit-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:clearOnReloadon a client mount, on a document load, with a lateSuspense reader, with a loader redirect's state.
location-state-options-server.test.tsandrender-handler.rsc-test.tsx:runMiddleware,runLoaderResult,runInRequestContextandrenderHandlerreturn{ [Def.__rsc_ls_key]: value }and nothing else.tests/cloudflare-basic/test/location-state-options.test.tsx: the sameagainst 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):own is kept (typed and plain; another version and no record).
dropped by a back/forward that loads the document.
loader-hmr"an RSC edit keeps the page's locationstate; the next document load drops it".
Red before green:
(
Expected "none",Received "desc:3").and the
loader-hmrcase fail (Expected "desc:3",Received "none").@rangojs/router: bothchecks 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). E2elocation-state: test-app dev 42, production 33; cloudflare-basic 17 and 17.Notes
two
createLocationStateoptions:version(a number the app bumps when astate's shape changes) and
validate(a guard run on every read). This PRships 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
validateis state the same build wrote in a shape its owntype 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.
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.
values as it always did.
versionoption ofcreateRouter()and of the handler,router.version,the
@rangojs/router:versionvirtual module, and the result ofinitBrowserApp()from@rangojs/router/browser(version,bridge.getVersion()). The record itself is readable inwindow.history.state, and therefore in thefrom.state/to.stateof atransition({ when })predicate, which is documented as the rawhistory.state.restart, drops location state, like a deploy.
until a write claims the entry or the browser drops it.
destination entry's location state to a mounted reader as soon as the entry
is pushed (
navigation-transaction.tsdispatches__rsc_locationstateincommit), before the destination's tree commits (
partial-update.ts). Theload-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.
version: numberwith a stored envelope. A four-angle review found it unsafe on rollback and a cost on every read for definitions without options.versionandclearOnReloadin the slot key, value stored raw.versionwas removed and location state became versioned by the router version, one stamp per history entry.validatewas dropped as well: with implicit versioning it had little left to do.To be filed separately: on a client navigation a mounted
useLocationStatereader 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.