Repository navigation
fix(router): location state belongs to the entry on screen; evicted same-route back/forward restores its page (#1029, #1030) - #1033
Merged
Conversation
…re it (#1029) A mounted useLocationState reader received the destination entry's state as soon as the router pushed the entry (on back/forward, at the popstate event), in a render of its own. For as long as a navigation was held, the page being left rendered with the next entry's state: a "load more" list showed the current page's items twice until the next page landed. The hook read history.state through useSyncExternalStore, and tx.commit told it with a window event. A store snapshot is the latest value in every tree at once, so it cannot stay behind in the tree a transition is holding. Location state now rides the route-state notification. A commit site moves an identity on the event controller (commitLocationState); the hook keeps its slot in React state and re-reads history.state when the identity moves. NavigationProvider already flushes that notification inside the payload update, so the read takes the payload's lane and commits with the destination's tree. useSyncExternalStore stays for its server snapshot. - push, replace, back/forward (cached restore and refetch) and server-set state on a navigation commit with the tree. - A revalidate:false navigation and a server action's state commit no tree and are delivered at once, as before. - clientUrls(): OptimisticLocation carries the entry the navigation will push, so the optimistic destination reads its own state on its first render instead of the entry being left. - The __rsc_locationstate window event and its dispatch sites are gone. - A reader whose slot a commit leaves as it was sets no state. - renderRoute: navigations commit through the controller, router.navigate() takes state/replace, and a popstate event is a back/forward onto the current history.state. The hook carries "use no memo": compiled by the React Compiler it grows a 29-slot memo cache (296B gzip in the router chunk). Router chunk on cloudflare-basic: 47077B -> 47100B gzip (limit 47104B).
…#1029) Browser coverage for #1029 in the router test-app and cloudflare-basic, dev and production, with the bodies shared in tests/shared-e2e. - The load-more fixture loads its page through a loader (`?hold=<ms>` delays it) and lists carried items plus the loader's page with no duplicate filter. `location-state.commit` holds a navigation for 2.5s and checks the screen during the hold, every DOM state in between and every list the fixture committed; then back/forward across cached entries and a return to an evicted entry, with a reader in a layout the two #994 fixtures now share. - The slow clientUrls group gets a reader in chrome outside the group, in the group layout and in each page: the optimistic destination reads the state its navigation carries, held content keeps its entry's state until the canonical commit, and a superseded or cancelled navigation leaves none behind. - The render-stability pages get a useLocationState probe: same render and commit counts as on main. - cloudflare-basic's dogfood unit test seeds the list's loader and pins the pending window through renderRoute. No route was added or renamed.
…e duplicate filter (#1029) - skills/hooks/state.md: the contract ("When a reader sees an entry's state") with a table per kind of change, and the load-more example as the plain concatenation. The paragraph that described the duplicate filter as a workaround is gone. - skills/client-urls, docs/client-urls.md: inside the optimistic branch useLocationState reads the state the navigation carries; outside it and in held content, the committed entry's. - skills/testing, docs/testing.md: router.navigate(url, { state, loaders }) for a component across a pending navigation, and a popstate event as a back/forward. - docs/internal/feature-map.md, feature-file-map.md: location state on the route-state notification, the commit sites, and why the hook is React state with useSyncExternalStore kept for the server snapshot only. - docs site hooks reference, shallow-navigation design note: same rule.
…ext (#1029) A reader that mounted while a navigation was pending read the destination entry's state in the tree still on screen: useLocationState took its first value from history.state, which has already moved by then. The first fix covered readers that were mounted and left this one open. The location state of the entry on screen is now React state owned by NavigationProvider, set in the same update as the payload and delivered by LocationStateContext. A transition React holds renders the destination's value; an urgent render of the content on screen, a reader mounting there included, renders the value of the entry being left. The hook is a context read behind the per-reader hydration snapshot: no identity, no mirror ref, no subscription, no frozen first read. - Every commit site hands the controller the entry state it pushed, restored or merged (EventController.commitLocationState(entryState)); the snapshot is built from that object, under the version and clearOnReload rules, not from a later read of history.state. The provider takes it next to setPayload. A commit no payload follows (shallow navigation, a commit that keeps every segment, an action's state) is marked treeless and taken with that commit's state notification, in the batch of the location. - A value keeps its identity when a navigation carries the reader's own value forward or an action changes another slot; any other object is a new value, and a structured clone of what the caller passed. - An action's state is added to what readers hold instead of re-reading the entry, so a flash value on screen survives it. - write()/delete() and the flash clear change history only: readers see them when the entry is next restored. A flash value is shown until the entry's state is next committed. - Outside a NavigationProvider a reader has no location state. - The "use no memo" directive is gone. Router chunk 47077B -> 47262B gzip; limit raised 46KB -> 47KB with a dated note (47167B with the directive, so it would not have fit either). - renderRoute commits through the same calls: its popstate listener is the bridge's registerLinkInterception, and router.navigate() writes history only for `state` or `replace: true`. - The optimistic clientUrls() entry state is built only when the group presents the target.
…on screen (#1029) Both apps, dev and production: the load-more fixture mounts a second reader of the carried items on demand. Pressed while the next page's loader holds the navigation, it mounts in the page still on screen and must read that page's entry, then change with the list. The DOM sampler flags any state in which the late reader disagrees with the list it is in. The cloudflare-basic dogfood test covers the same through renderRoute. The test-app's load-more and app-version routes move to their own urls() module, included at the same paths and names, so location-state.tsx keeps its existing patterns untouched.
…t entry's page (#1030) Going back or forward to an entry of the same route that differs only in search params, after the history cache dropped it, changed the URL and the entry's location state and left the page being left on screen: a load-more list past 20 pages showed the wrong page's items on Back. Cause: the partial fetch tells the server which page the client is on (X-RSC-Router-Client-Path) from the transaction's URL, window.location at the time the transaction is created. On a back/forward the popstate event has already moved it to the target, so the server diffed the target against itself: on the same route nothing revalidates, the diff is empty, and the client commits without a tree. A different route still got its segments, which is why only the same-route case showed. A traversal now names the store's URL, the committed entry's, as the leave-intercept path already did for the same reason. An intercept restore keeps naming the intercept's source; push and replace keep the transaction's URL. Tests: partial-update unit tests for the three cases, and an e2e in both apps, dev and production, that evicts through the same route and returns with the refetch held by its loader.
…opstate limit (#1029, #1030, #1031) The hooks skill and the docs-site page state the contract as built: a reader gets an entry's location state with that entry's page, including a reader that mounts while a navigation is pending, and what follows from the provider holding it (identity of values, write()/delete(), flash, a reader outside the provider). Both name the limit that remains: usePathname and useSearchParams still change at popstate on back/forward (#1031). CHANGELOG: the #1029 entry is rewritten around the same contract and lists every change in when a reader updates that an existing app could notice; a #1030 entry is added. Also: the optimistic clientUrls() value is the object the navigation passed and becomes the entry's copy at the commit (documented, not cloned); renderRoute's navigate() history options and its popstate listener; the internal feature maps and the shallow-navigation design note follow the provider-owned delivery.
…before measuring cloudflare-basic's `default-prefetch-none › an offscreen viewport override waits for intersection` read the link's position right after the shell hydrated. When the document streams the layout as its own segment, React reveals it a moment later (measured: 179ms); until then the link sits in a hidden `S:1` node and has no box: `Expected: > 920 Received: 0`. The three client-urls-slow tests this branch adds run next to it in a local two-worker dev run, and with them it lost that race in 2 of 2 full runs and 3 of 3 runs of the nine files around it. Alone it passed 4 of 4, and on the same source without those three tests the nine files pass. On origin/main it passed 2 of 2 full dev runs. Wait for the link to be visible, then measure. The assertion is unchanged.
commit: |
This was referenced Oct 4, 2026
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.
Closes #1029. Closes #1030. References #1031.
Two fixes.
useLocationStatereturns a history entry's state only together with that entry's page, for a reader that is mounted across a navigation and for one that mounts while the navigation is pending (#1029). Back/forward to an entry of the same route that the history cache dropped now restores that entry's page instead of keeping the page being left under the entry's URL (#1030).Problem
#1029. On a client navigation the router pushes the history entry once the response has arrived, and React commits the destination afterwards. A navigation that waits on a loader keeps the current page on screen until then.
useLocationStatedid not wait: it readhistory.state, which has already moved, so the page being left rendered with the next entry's state. The visible case is a "load more" list:?page=Nloads a page through a loader, and the items on screen travel to the next page as location state on the<Link>. After the click, every item of the current page was listed twice until the next page's data landed, and React logged duplicate-key warnings in development. Back/forward had the same shape. A reader that mounted during the wait had it too, whichever way mounted readers were delivered to.#1030. Pressing Back to
?page=3from?page=24of the same list, once page 3's entry had left the 20-entry history cache, changed the URL and the entry's location state and left page 24 on screen.Before vs after
The same component, the same click on "Load more" on page 5, while page 6's loader is still streaming:
?page=6is pendingcarried?page=3from?page=24, entry no longer cached?page=3?page=3End-to-end example
No duplicate filter, and Back through a list longer than the history cache lands on the page the URL names.
The delivery model
The location state of the entry on screen is React state owned by
NavigationProvider, next to the payload, set in the same update and delivered by context:A transition React holds renders the destination's value; an urgent render of the content on screen, a reader mounting there included, renders the value of the entry being left. That is React's own state semantics, so no ordering between a notification and a payload is relied on.
EventController.commitLocationState(entryState). The controller stores a snapshot (locationStateSnapshot: the typed slots and plainstate, only when recorded under the client's version) and notifies nobody; the provider takes it next tosetPayload. It is never a later read ofhistory.state.treeless: arevalidate: falsenavigation, a commit whose response kept every segment, and a server action's state. The provider takes those with the commit's own state notification, in the batch that movesusePathname. A tree commit never sets the mark, so nothing can deliver it early.__rsc_locationstatewindow event is gone, and so are the commit identity and per-reader subscription the first three commits of this branch replaced it with. The hook has no state, ref, subscription or frozen first read.useSyncExternalStorestays for its server snapshot only, per reader, so every hydrating render returnsundefined.When a reader sees an entry's state
transition(), held loader)popstate)usePathname)revalidate: falsesetLocationStateDef.write()/Def.delete(), the flash clearclientUrls()optimistic destinationundefinedwhile hydrating, then the entry's stateWhat an existing app can notice
Listed in full in the CHANGELOG entry. In short:
Def.write()/Def.delete()no longer sees the change; it does when the entry is restored.Def.read()returns it at once. The JSDoc promised "the next mount" before.flashvalue is shown until the next navigation commits, by every reader: a reader mounted across a later navigation no longer keeps it, and one that mounts later in the same visit now gets it.write().undefined(it readhistory.statebefore).Code changes
src/browser/react/NavigationProvider.tsx,context.ts,location-state.ts,location-state-shared.ts(the snapshot);src/browser/event-controller.ts(the holder); the commit sitesnavigation-transaction.ts,navigation-bridge.ts,server-action-bridge.ts,partial-update.ts,rsc-router.tsx;src/ssr/ssr-root.tsx(same provider nesting, valueundefined);src/client-urls/navigation.ts(the optimistic entry state is built only when the group presents the target);src/testing/render-route.tsx.#1030 is one expression in
src/browser/partial-update.ts:tx.currentUrliswindow.locationwhen the transaction is created; on a back/forward thepopstateevent has already moved it to the target, so the server was told the client is on the target, diffed the target against itself, and on the same route found nothing to re-render. The store's URL is the committed entry's. The leave-intercept path already used it for the same reason.renderRoute: itspopstatelistener is registered through the bridge'sregisterLinkInterceptionslot and commits with the same controller call as production.router.navigate()writes a history entry only forstate(push) orreplace: true;navigate(url)andnavigate(url, { replace: false })leave history alone.The test-app's
/load-moreand/app-versionroutes moved into their ownurls()module, included at the same paths and names;location-state.tsxdiffers from main by 4 insertions and 36 deletions. No*.gen.tschanged.Bundle
The
"use no memo"directive is removed. Router client chunk, gzip: main 47077B, the first three commits of this branch 47100B (with the directive), now 47270B (47262B for #1029, 8B for #1030). With the directive restored the reworked hook measured 47167B, so it would not fit the old 47104B limit either.ROUTER_CHUNK_GZIP_MAXis raised 46KB -> 47KB (48128B) with a dated note intools/check-bundle-guards.mjs: a whole KB, like the earlier steps in that file, because main sat 27B under the old limit and a smaller step would trip on the next change. The hook itself shrank; the snapshot, its holder on the controller and the provider's context are what grew.Test plan
Red before green:
render-route-location-state-commit.test.tsx), on27a41ffb:AssertionError: expected 'page 1: p1-1+p1-2+p1-3' to be 'page 1: none'. E2e (router app, dev), same source:Expected: "1:0" Received: "1:3", 3 of 3 attempts. Green on this branch.partial-update.test.ts), without the fix:AssertionError: expected 'http://localhost/list?page=3' to be 'http://localhost/list?page=6'. E2e (router app, dev and production), without the fix: the screen heldpage: "23"with itemsp1-1, p1-2, p1-3, p23-1, p23-2, p23-3andlate: "23:3"(page 23's tree under page 2's URL and state). Green with it in both modes.Unit, through
renderRoute(src/testing/__tests__/render-route-location-state-commit.test.tsx):write()/delete()change the entry, not what readers showrouter.navigate()writes a history entry only forstateorreplace: truepopstateevent is a back/forward onto the entryUnit, internal:
event-controller.test.ts(recording, the treeless mark, identity, copy),navigation-transaction.test.ts(the object pushed / restored / merged is the one recorded; treeless),navigation-bridge*.test.ts(shallow commit, cached restore order, navigations that do not commit),server-action-bridge.test.ts(an action's slots on top of what readers hold; a flash value survives),partial-update.test.ts(treeless on the no-changes path; the threepreviousUrlcases for #1030),location-state-hydration.test.tsx,location-state-version.test.tsxandlocation-state-version-surface.test.tsx(now through the realNavigationProvider),client-root.test.tsx.Dogfood:
tests/cloudflare-basic/test/location-state-options.test.tsx(held load-more with a late reader, on the app's own list).E2e, both apps, dev and
(production):lm-more-cold): see the first note.Results:
pnpm run typecheck16/16;pnpm run test:unit:allgreen (router 7208 passed + 445 RSC; mini 25 + 3; cloudflare-basic 69 + 17; vite-rsc-demo 25 + 1; shared-e2e 8);pnpm run lint0 warnings;pnpm run formatclean;pnpm check:e2e-bucketing,pnpm check:e2e-parity --strict,pnpm run check:docs-api(266 identifiers) andpnpm check:bundle-guardsgreen.location-state.test.tsis 37 of 37 in production and 46 of 46 in dev. Semantic matrix 25 dev / 28 production. Hook render stability 8 dev / 8 production, no pin changed.--repeat-each=3 --retries=0, router app: 6 of 6 in dev, 6 of 6 in production.--no-deps, oflocation-state,client-urls-slowandhook-render-stability(plusdefault-prefetch-noneon cloudflare-basic): router app 61 passed in dev and 52 in production; cloudflare-basic 31 passed in dev and 31 in production. The gate was run again on the final commit with the same counts.default-prefetch-none › an offscreen viewport override waits for intersectionfailed in 2 of 2 full dev runs before the last commit (Expected: > 920 Received: 0), passed 4 of 4 alone, and passed 2 of 2 full dev runs on origin/main (eed288b1). Cause and fix are in the last commit: the test measured a link inside a streamed segment React had not revealed yet (179ms later it had), and the three client-urls-slow tests this branch adds run next to it. With the wait: 0 of 1 full run.same-route-nav › same-route nav keeps old content and never flashes the skeletonfailed in 1 of 3 full dev runs on this branch and 0 of 2 on origin/main in this round. With--repeat-each=5, measured earlier on this branch, it failed 2 of 5 on the branch and 2, 2 and 3 of 5 on origin/main. Not touched.Notes
prefetchCacheTTLunder a key that does not include the source page, and a navigation fetch consumes it before asking the server (navigation-client.ts,consumePrefetch(wildcardKey)). So with link prefetching on (the production default is"viewport"), a return to an evicted entry whose URL a link had prefetched within the TTL was served from that cache and showed the right page before this fix too. The bug needs the server to be asked: prefetch off, the TTL expired, the cache rotated by an action, or an entry no link prefetched. The first production run of the new e2e failed for that reason (the page came back at once, with no hold to observe), so the fixture has a second load-more link to a URL of its own withprefetch="none", and the test goes through it. Without the fix that test is red in production as well.usePathname/useSearchParamson back/forward (usePathname / useSearchParams change at popstate, before the destination tree commits; one 'committed entry' for URL, params and location state #1031). They still change atpopstate. For the window of a refetched traversal the page on screen reads the destination's URL from them and its own entry's state fromuseLocationState. Documented in the hooks skill, the docs page and the CHANGELOG; not changed here.navigation-bridge.ts, "Failed to render from cache"): the store has already moved to the target there, so that fetch still names the target as the source. Not reachable by a test I could write without faking a render failure; left as is.renderRouteseeds are copied like any entry state. AlocationStateseed that history could not store (a function, say) now fails the render withDataCloneError, where the hook used to hand it back as is.tests/cloudflare-basic/e2e/default-prefetch-none.test.ts): oneawait expect(link).toBeVisible()before the measurement, assertion unchanged. It is its own commit so it can be dropped.clientUrls()value is not cloned; the difference is documented indocs/client-urls.mdand the client-urls skill. Cloning there would turn a value history cannot store into an error before the fetch.NavigationProvider: a reader that mounted during a held navigation still read the destination's state. Applied from the simplify angles: a 600-line re-indentation oflocation-state.tsxundone (43 real lines), noclientUrls()work on a navigation that no group presents,renderRoute's back/forward on the production controller call,"use no memo"removed. Skipped: nothing.