Skip to content

fix(router): on Back/Forward, usePathname and useSearchParams change with the page, not at popstate (#1031) - #1049

Merged
ivogt merged 1 commit into
mainfrom
fix/1031-committed-entry
Oct 4, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/1031-committed-entry

Conversation

@ivogt

@ivogt ivogt commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #1031. References #1046 (what remains), #1047 and #1042 (item 1).

On Back/Forward, usePathname() and useSearchParams() now change when the entry's page commits, as they do on a push. They used to change at the popstate event, 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().path and useNavigation().location did not wait: they changed at the popstate event. 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 and router.push() / router.replace() never did this, and useParams and useLocationState already waited (#1033).

Reproduced in a browser before anything was changed, in both apps, dev and production: within 2 ms of popstate every 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=2 from /products?page=23, the entry fetched again, while its request is out (page 23 on screen):

const pathname = usePathname();
const [searchParams] = useSearchParams();
const nav = useNavigation();
While the request is out searchParams.get("page") nav.location nav.pendingUrl Address bar
Before "2" (the destination) /products?page=2 /products?page=2 /products?page=2
After "23" (the page on screen) /products?page=23 /products?page=2 /products?page=2
After, a reader mounted now "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

// src/components/ProductTabs.tsx
"use client";
import { Link, useNavigation, useSearchParams } from "@rangojs/router/client";

const CATEGORIES = ["all", "kettles", "mugs"];

export function ProductTabs() {
  const [searchParams] = useSearchParams();
  // True from the start of a navigation's request until its stream ends,
  // for a link click and for Back/Forward alike.
  const busy = useNavigation((nav) => nav.isStreaming);
  const active = searchParams.get("category") ?? "all";
  return (
    <nav aria-busy={busy}>
      {CATEGORIES.map((category) => (
        <Link
          key={category}
          to={`/products?category=${category}`}
          aria-current={category === active ? "page" : undefined}
        >
          {category}
        </Link>
      ))}
    </nav>
  );
}

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-busy covers 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

Navigation Before After
<Link>, router.push() / router.replace() when the destination commits unchanged
Back/Forward, entry in the history cache at popstate, the page in the same frame with the restored page, in one commit; no visible difference
Back/Forward, entry fetched, request out at popstate when the entry's page commits
Back/Forward, entry fetched, response arrived, React holds the old page at popstate when the entry's page commits, for components already mounted
the same, a component that first mounts during that part the destination the destination: unchanged, the known gap (#1046)
Back/Forward whose fetch fails at popstate with the error boundary; they report the entry the browser is on
Back/Forward superseded by another navigation at popstate, then the next navigation's not at all; the next navigation's
router.push(url, { revalidate: false }), server actions unchanged unchanged
Back/Forward the server answers with a redirect error boundary unchanged (#1047)

Is 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 Where the location is written T against R
push / replace, urgent T: navigation-transaction.ts:219, event-controller.ts:704 one batch: NavigationProvider.tsx:487 flushes the notification in the payload's lane. A destination that suspends outside any Suspense boundary is held (2.5 s measured).
push, held (same route, transition(), held loader) T T, then R after the hold (partial-update.ts:81-93). Mounted readers get the value in the held batch; a reader mounted in between reads T's value (#1046).
Back/Forward, cached the restore block, navigation-bridge.ts:646 (was: at the event) no transaction; the restore is the commit, the hooks update in the payload's batch.
Back/Forward, fetched T: navigation-transaction.ts:203 (was: at the event, T rewrote the same value) as a push: the request-out part is before T, the held part between T and R.
Back/Forward, fetch failed the failure path, navigation-bridge.ts:787 (was: at the event) no T; the location moves in the error boundary's batch.
shallow (revalidate: false) navigation-bridge.ts:244 no transaction and no payload: it lands on the tree on screen at once (#1042 item 1 when that tree is being held).
server action none not applicable.

"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"

Consumer Before, on a fetched Back/Forward After Follows
usePathname (use-pathname.ts:25-48) at the event R if mounted; T if it mounts during a hold (#1046) the page on screen
useSearchParams read (use-search-params.ts:120-145) at the event same the page on screen
useSearchParams setter (:156) builds on the destination builds on the page on screen the page on screen
useParams (use-params.ts:52-75) R if mounted; T if mounted during a hold unchanged the page on screen
useLocationState R for every reader (#1033) unchanged the page on screen
useSegments().path (use-segments.ts:76,100) at the event as usePathname the page on screen
useNavigation state, pendingUrl, isStreaming loading + pendingUrl until T, isStreaming until the stream ends unchanged the pending navigation
useNavigation().location at the event (equal to pendingUrl) T the page on screen until T
<Link> pending (use-link-status.ts) from pendingUrl unchanged the intended URL
<Link> prefetch re-arm (Link.tsx:468, link-interceptor.ts:565) at the event, before the store moved at T, after it the committed location
useRouter reads no location unchanged
transition({ when }) (transition-when.ts) from from the store, to from the event's URL and history.state unchanged the store and the browser's entry
scroll restoration (scroll-restoration.ts) history.state key; saved at the event, restored at R unchanged the browser's entry: it must keep doing so
fetch URL, history key, intercept detection (navigation-bridge.ts:537-565) window.location, history.state unchanged the intended URL
clientUrls() optimistic location never presented on Back/Forward unchanged the intended URL, in its branch
server actions (server-action-bridge.ts) window.location, history.state.key unchanged the browser's entry

Nothing outside React reads the controller's location, so nothing needed it at the popstate event. Everything that needs the intended URL at the event reads window.location and history.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 its onUpdate; the location joins them.

No public API, no new file, no route change, no *.gen.ts change.

Test plan

Red before green. Source reverted to origin/main (d38f50ff), tests and fixtures from this branch:

  • Unit, src/__tests__/navigation-bridge-traversal-location.test.ts, 5 of 5 failed:
    • :158 request out: "location": "http://localhost/list?page=2", expected "http://localhost/list?page=23".
    • :189 cached 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.
  • The failure-path line on its own, source with only the popstate write 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.
  • E2e, 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).

Green on this branch:

  • Unit (navigation-bridge-traversal-location.test.ts, the real event controller, transaction and store; only the partial updater is replaced):
    • a refetched traversal leaves the location alone until its transaction commits (state "loading", pendingUrl the entry, location the page on screen);
    • a traversal restored from the history cache sets the location in its restore block, with params and location state, before the update;
    • a refetched traversal that fails (network failure, unprocessable response) reports the entry with its error boundary;
    • a superseded traversal moves nothing.
  • E2e through shared bodies (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):
    • a refetched back keeps the URL of the page on screen while its request is out, same route and cross route: readers already mounted (the page's and the shared layout's), a reader mounted during the wait, useNavigation() (loading, pendingUrl the entry, location the 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;
    • a refetched back held by its loader keeps the URL of the page on screen for mounted readers;
    • known gap A reader that mounts while React holds a navigation reads the destination's URL and params (usePathname, useSearchParams, useParams, useSegments) #1046: a reader mounted while React holds a refetched back reads the page's URL. Expected failure (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;
    • control: a push keeps the URL of the page on screen while its request is out;
    • control: a cached back/forward changes the URL with its page.
  • The new e2e file, --repeat-each=5 --retries=0: 60 of 60 in the router app, 60 of 60 in cloudflare-basic.
  • Render-count pins unchanged: hook-render-stability 8 dev and 8 production in the router app, 4 and 4 in cloudflare-basic.

Results on the final commit (origin/main d38f50ff merged; the branch is one commit on top of it):

  • Gate, from the worktree root: pnpm run typecheck 16 of 16; pnpm run test:unit:all green (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 lint 0 warnings; pnpm run format clean; pnpm check:e2e-bucketing, pnpm check:e2e-parity --strict, pnpm run check:docs-api (269 identifiers) and pnpm check:bundle-guards green.
  • E2e, dev and production, --no-deps, files matching location-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 (a test.fixme in prerender-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.
  • Client router chunk: 47273 B gzip, limit 48128 B; origin/main measured 47270 B.

Notes

  • What remains: A reader that mounts while React holds a navigation reads the destination's URL and params (usePathname, useSearchParams, useParams, useSegments) #1046. A component that first mounts in the page being left after the response arrived, while React still holds the destination, reads the destination's URL from usePathname(), useSearchParams(), useParams() and useSegments(), and useNavigation().location is 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.
  • No userland test through renderRoute. The primitive cannot hold a traversal: its history entries carry no URL (router.navigate() writes an entry only for state or replace: true, always under the same address), and its popstate path commits history.state without a tree. Holding one would need every navigation to record its URL per entry, a popstate path 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 of router.navigate(). Coverage is the bridge-level unit test, which runs the real controller, transaction and store, and the e2e.
  • Location state and back/forward: three paths #1033 leaves without a test #1042 item 1 reproduces and is not settled here. A revalidate: false push 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.
  • Back/Forward to an entry the server now redirects is not followed: "Unprocessable popstate response" and an error boundary #1047 (a Back/Forward the server answers with a redirect is not followed) is unchanged: it still ends in the error boundary, now through the failure path above.
  • 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.
  • Scroll restoration, transition({ when }), the fetch URL, intercept detection and server actions read window.location, history.state and 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-matrix 25 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 a test.fixme in source (tx-when-transition, deferred-handle-nav, prefetch-transition, cache, static-handler).
  • Docs. Hooks skill pages (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.
  • Fixtures. The load-more fixture of both apps gained URL readers in the list, the panel next to it and the shared layout, a useNavigation() reader in the layout, a reader each page mounts on demand, and a router.push control. No route was added.
  • History. The exploratory commits (a recording probe and the first version of the change) were replaced by one commit on top of origin/main; the tree is the one all results above were measured on.
  • Review. One independent four-angle pass (reuse, simplification, efficiency, altitude) on the final diff. Applied: a sentence about a failed push removed from the failure-path comment (it described another code path). Checked and kept: the three sites that set the location (the transaction for a fetch, the restore block for a cached entry, the failure path) are the real shape, since a cached restore and a failure have no transaction; the expected-failure test for A reader that mounts while React holds a navigation reads the destination's URL and params (usePathname, useSearchParams, useParams, useSegments) #1046 calls test.fail only after its setup assertions, so a failure before that point is reported as a failure (seen when the test ran against main'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.
  • Second run after the review edit: the new file with location-state and hook-render-stability, --no-deps: router app 60 passed in dev and 51 in production; cloudflare-basic 27 and 27. With main's navigation-bridge.ts the new file fails 4 of 6 in the router app's dev project (the two controls pass).
  • A cached restore that is overtaken by a second Back/Forward while it renders still commits its tree last, as on main (the restore block has no check after await 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.

…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.
@pkg-pr-new

pkg-pr-new Bot commented Oct 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a6cbae5

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.

usePathname / useSearchParams change at popstate, before the destination tree commits; one 'committed entry' for URL, params and location state

1 participant