Repository navigation
fix(router): on Back/Forward, usePathname and useSearchParams change with the page, not at popstate (#1031) - #1049
Merged
Merged
Conversation
…with the page, not at popstate (#1031) On Back/Forward to an entry that has to be fetched, the page being left stays on screen for the wait. usePathname(), useSearchParams(), useSegments().path and useNavigation().location changed at the popstate event, so that page read the destination's URL until the entry's page committed. A push never did this: its location moves in tx.commit(). handlePopstate no longer sets the controller's location at the event: - a traversal that fetches gets it from tx.commit() (handle.complete), the place a push gets it; - a traversal restored from the history cache sets it in its restore block, next to params and location state, before onUpdate; - a traversal whose fetch fails sets it with the error boundary: history is already on that entry and the boundary replaces the page being left. A failed push keeps the source URL in the hooks and in the address bar. During a fetched Back/Forward the hooks now disagree with the address bar, which the browser changes before the event. useNavigation() reports the wait: state "loading" and pendingUrl while the request is out, isStreaming until the stream ends. Left open, with an expected-failure e2e that names it: a reader that first mounts after tx.commit() while React still holds the destination reads the destination's URL (#1046). A push has the same gap on main. Tests: navigation-bridge-traversal-location.test.ts (real controller, transaction and store); navigation-url-commit.test.ts in the router test-app and cloudflare-basic, dev and production, through shared bodies. renderRoute cannot hold a traversal (its history entries carry no URL), so there is no userland test.
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 #1031. References #1046 (what remains), #1047 and #1042 (item 1).
On Back/Forward,
usePathname()anduseSearchParams()now change when the entry's page commits, as they do on a push. They used to change at thepopstateevent, so while an entry was being fetched the page being left read the destination's URL.Problem
Pressing Back or Forward to an entry that is no longer in the client's history cache makes the router fetch it, and the page being left stays on screen for the wait.
usePathname(),useSearchParams(),useSegments().pathanduseNavigation().locationdid not wait: they changed at thepopstateevent. For the whole wait an active-link highlight, a breadcrumb, a tab strip or a filter panel derived from the URL showed the destination next to the old page's content. A link click androuter.push()/router.replace()never did this, anduseParamsanduseLocationStatealready waited (#1033).Reproduced in a browser before anything was changed, in both apps, dev and production: within 2 ms of
popstateevery mounted reader showed the destination while the page being left stayed on screen for the 2.5 s the fetch was held.Before vs after
The same component, Back to
/products?page=2from/products?page=23, the entry fetched again, while its request is out (page 23 on screen):searchParams.get("page")nav.locationnav.pendingUrl"2"(the destination)/products?page=2/products?page=2/products?page=2"23"(the page on screen)/products?page=23/products?page=2/products?page=2"23"During the wait the hooks disagree with the address bar. That is the intended result: the browser changes the address bar before it tells the router, and the hooks describe the page on screen.
End-to-end example
The tab strip sits in a layout above the product list. With this change the highlighted tab is the category of the list on screen at every moment, including after Back to an entry the router has to fetch: the highlight moves in the commit that brings that entry's list, and
aria-busycovers the wait. Before, Back moved the highlight at once and left it next to the other category's products until the fetch landed.Semantics
When the URL hooks change
<Link>,router.push()/router.replace()popstate, the page in the same framepopstatepopstatepopstatepopstate, then the next navigation'srouter.push(url, { revalidate: false }), server actionsIs the transaction commit the same moment as React's commit?
T is the transaction's commit (
navigation-transaction.ts:146), R is React's commit of the destination tree.navigation-transaction.ts:219,event-controller.ts:704NavigationProvider.tsx:487flushes the notification in the payload's lane. A destination that suspends outside any Suspense boundary is held (2.5 s measured).transition(), held loader)partial-update.ts:81-93). Mounted readers get the value in the held batch; a reader mounted in between reads T's value (#1046).navigation-bridge.ts:646(was: at the event)navigation-transaction.ts:203(was: at the event, T rewrote the same value)navigation-bridge.ts:787(was: at the event)revalidate: false)navigation-bridge.ts:244"Commit with the transaction" is sufficient for the wait a user sees most, the network wait, for every reader, and for the React hold for readers already mounted. It is not sufficient for a reader that mounts between T and R. A push has that gap on main, so "like a push" inherits it: #1046.
Consumers of "where are we"
usePathname(use-pathname.ts:25-48)useSearchParamsread (use-search-params.ts:120-145)useSearchParamssetter (:156)useParams(use-params.ts:52-75)useLocationStateuseSegments().path(use-segments.ts:76,100)usePathnameuseNavigationstate,pendingUrl,isStreamingloading+pendingUrluntil T,isStreaminguntil the stream endsuseNavigation().locationpendingUrl)<Link>pending (use-link-status.ts)pendingUrl<Link>prefetch re-arm (Link.tsx:468,link-interceptor.ts:565)useRoutertransition({ when })(transition-when.ts)fromfrom the store,tofrom the event's URL andhistory.statescroll-restoration.ts)history.statekey; saved at the event, restored at Rnavigation-bridge.ts:537-565)window.location,history.stateclientUrls()optimistic locationserver-action-bridge.ts)window.location,history.state.keyNothing outside React reads the controller's location, so nothing needed it at the
popstateevent. Everything that needs the intended URL at the event readswindow.locationandhistory.state.A Back/Forward whose fetch fails
Once the failure puts an error boundary on screen, the hooks report the entry the browser is on: the boundary replaces the page being left, and the address bar already shows that entry. The location is set in the failure path, right before the boundary update, so both land in one batch. This is the end state main had (main set the location at the event). A failed push is the mirror image and is not changed: history was never written, so the hooks and the address bar both keep the source URL under its boundary. In both cases the hooks agree with the address bar once the boundary is up.
Code changes
packages/rangojs-router/src/browser/navigation-bridge.ts, 12 insertions and 5 deletions, comments included;event-controller.ts, one comment. Non-test source diff: 14 insertions, 6 deletions.async handlePopstate() { ... - // Update location in event controller - eventController.setLocation(new URL(url)); ... if (cachedSegments && cachedSegments.length > 0) { ... const root = await renderSegments(cachedSegments, { ... }); store.rememberDisplayedEntry(); + eventController.setLocation(new URL(url)); eventController.setParams(cachedParams); eventController.commitLocationState(historyState); ... onUpdate(popstateUpdate) } const tx = createNavigationTransaction(store, eventController, url, { replace: true }); try { await fetchPartialUpdate(url, undefined, false, tx.handle.signal, tx.with({ url, traversal: true, ... })); } catch (error) { if (aborted or superseded) return; + eventController.setLocation(new URL(url)); ... emitNetworkError / emitNavigationError }A traversal that fetches needs no new code:
tx.commit()already completes the navigation handle with the entry's URL (navigation-transaction.ts:203), which is how a push moves the location. The early write at the event was the only thing in the way.Why the cached restore does not go through
tx.commit(): a commit caches the committed segments with the handle data currently on screen (navigation-transaction.ts:183-184), which would overwrite the restored entry's cached handle data with the page being left's. The restore block already commits params and location state next to itsonUpdate; the location joins them.No public API, no new file, no route change, no
*.gen.tschange.Test plan
Red before green. Source reverted to
origin/main(d38f50ff), tests and fixtures from this branch:src/__tests__/navigation-bridge-traversal-location.test.ts, 5 of 5 failed::158request out:"location": "http://localhost/list?page=2", expected"http://localhost/list?page=23".:189cached restore, tree still rendering:expected 'http://localhost/list?page=2' to be 'http://localhost/list?page=23'.:231(both failure cases) and:265(superseded): the same line, at the request-out check.popstatewrite removed: 2 of 5 failed at:239,"location": "http://localhost/list?page=23", expected"http://localhost/list?page=2"in the error boundary's update.navigation-url-commit.test.ts, both apps, dev and production: 8 failed and 4 passed in each app (the 4 are the two controls in each mode).tests/shared-e2e/src/index.ts:1377, request out, same route:pageUrlandlayoutUrl"/location-state/load-more?page=2&cold=1", expected"/location-state/load-more?page=23".:1377, cross route:"/location-state/load-more?page=2&cold=1", expected"/location-state/app-version?step=filler-21".:1440, loader held:"/location-state/load-more?page=2&cold=1&hold=2500", expected"/location-state/load-more?page=23".:1480, the A reader that mounts while React holds a navigation reads the destination's URL and params (usePathname, useSearchParams, useParams, useSegments) #1046 test's precondition (mounted readers): the same values as:1440.Green on this branch:
navigation-bridge-traversal-location.test.ts, the real event controller, transaction and store; only the partial updater is replaced):state"loading",pendingUrlthe entry,locationthe page on screen);tests/shared-e2e/src/index.ts), router test-app and cloudflare-basic, a dev and a(production)describe in each (navigation-url-commit.test.ts):useNavigation()(loading,pendingUrlthe entry,locationthe page on screen), the address bar already the entry's; then every reader shows the entry's URL with its page; no DOM state in between pairs a URL with another page;test.fail): its preconditions have to pass, its last assertion fails today (Expected: "/location-state/load-more?page=23",Received: "/location-state/load-more?page=2&cold=1&hold=2500",index.ts:1490), and the test turns red the day that assertion passes;--repeat-each=5 --retries=0: 60 of 60 in the router app, 60 of 60 in cloudflare-basic.hook-render-stability8 dev and 8 production in the router app, 4 and 4 in cloudflare-basic.Results on the final commit (
origin/maind38f50ffmerged; the branch is one commit on top of it):pnpm run typecheck16 of 16;pnpm run test:unit:allgreen (router 7532 passed and 484 RSC; mini 25 and 3; cloudflare-basic 69 and 17; vite-rsc-demo 25 and 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(269 identifiers) andpnpm check:bundle-guardsgreen.--no-deps, files matchinglocation-state same-route-nav client-urls hook-render-stability navigation scroll: router app 269 passed (145 dev, 124 production), 0 failed; cloudflare-basic 125 passed (61 dev, 64 production), 1 skipped (atest.fixmeinprerender-navigation.test.ts), 0 failed. Both counts include the two A reader that mounts while React holds a navigation reads the destination's URL and params (usePathname, useSearchParams, useParams, useSegments) #1046 expected failures of each app. No flake in these runs; load averages between 4 and 14.origin/mainmeasured 47270 B.Notes
usePathname(),useSearchParams(),useParams()anduseSegments(), anduseNavigation().locationis ahead of the screen for that part. It is the gap a held push has on main, so it needs the provider-state design described in the issue, not this change. The e2e above pins it as an expected failure. It also showed up once on a plain push sequence while writing these tests (1 of 6 dev runs): a page mounted by one push while the next push had already committed its transaction showed the next entry's URL over its own content. The shared bodies therefore sample the URL readers from a settled page on, not through the back-to-back pushes that evict the entry.renderRoute. The primitive cannot hold a traversal: its history entries carry no URL (router.navigate()writes an entry only forstateorreplace: true, always under the same address), and itspopstatepath commitshistory.statewithout a tree. Holding one would need every navigation to record its URL per entry, apopstatepath that resolves and renders that entry's route, and a way for a test to hand the traversal its loader values, which is a new public option. That is more than the roughly 60 lines that were the bar, and it changes the documented contract ofrouter.navigate(). Coverage is the bridge-level unit test, which runs the real controller, transaction and store, and the e2e.revalidate: falsepush while another push is held puts the shallow entry's URL and its location state on the page being left within about 15 ms, for the rest of the hold (1.9 s in the probe). Shallow navigations write the location themselves and are untouched by this PR.setSearchParams((prev) => ...)during a pending Back/Forward now builds on the search of the page on screen and pushes from the entry the browser is already on. Before, it built on the destination's search. Either is an edge; the new one matches what the user sees.transition({ when }), the fetch URL, intercept detection and server actions readwindow.location,history.stateand the store, and are not touched. On the final commit the other e2e files that use Back/Forward passed as well: router app 228 of 228 (semantic-matrix25 dev and 28 production,transition-when-browser,url-hooks,use-router,handle-popstate-regression,prefetch-transition,deferred-handle-nav,error-boundary,not-found,revalidation); cloudflare-basic 137 passed, 1 skipped by atest.fixmein source (tx-when-transition,deferred-handle-nav,prefetch-transition,cache,static-handler).skills/hooks/urls.md: "When the URL hooks change";navigation.md: "Pending UI, Back/Forward included";state.md: the usePathname / useSearchParams change at popstate, before the destination tree commits; one 'committed entry' for URL, params and location state #1031 limit replaced), the hooks reference (apps/docs/content/docs/rango/reference/hooks.mdx),docs/internal/feature-map.md(where the location is written), and the CHANGELOG entry with what an existing app can notice. The fix(router): location state belongs to the entry on screen; evicted same-route back/forward restores its page (#1029, #1030) #1033 CHANGELOG entry no longer lists this as a remaining limit.useNavigation()reader in the layout, a reader each page mounts on demand, and arouter.pushcontrol. No route was added.origin/main; the tree is the one all results above were measured on.test.failonly after its setup assertions, so a failure before that point is reported as a failure (seen when the test ran againstmain's handler: it failed at its setup check and counted as failed). Skipped: folding the new URL sampling harness into the load-more one from fix(router): location state belongs to the entry on screen; evicted same-route back/forward restores its page (#1029, #1030) #1033 and merging the request-held and loader-held cases into one setup (each case pushes 21 entries to evict one; four cases per mode); both are test-only and would trade isolation for about 20 s per app.location-stateandhook-render-stability,--no-deps: router app 60 passed in dev and 51 in production; cloudflare-basic 27 and 27. Withmain'snavigation-bridge.tsthe new file fails 4 of 6 in the router app's dev project (the two controls pass).main(the restore block has no check afterawait renderSegments). Before this PR the hooks then reported the newer entry over the older tree; now the location, params and tree all belong to the entry that committed last. Read from the code, not reproduced; the missing check is older than this PR.