Skip to content

A reader that mounts while React holds a navigation reads the destination's URL and params (usePathname, useSearchParams, useParams, useSegments) #1046

Closed as not planned

Description

@ivogt

A component that first mounts on the page being left, after a navigation's response has arrived but while React still holds the destination, reads the destination's URL and params from usePathname(), useSearchParams(), useParams() and useSegments(). Components that were already mounted keep the values of the page they are on. It happens on a push and on Back/Forward.

Found while working on #1031. useLocationState does not have this since #1033.

Evidence

Browser, load-more fixture, a push whose loader is held, both apps, dev and production:

  • the list on screen: page 1
  • readers already mounted: /load-more?page=1 (their own entry)
  • a reader mounted during the hold: /load-more?page=2&cold=1 (the destination)

Unit, through renderRoute, a held push on /items/:id with a transition:

  • mounted reader: /items/1?tab=a, params.id=1
  • reader mounted during the hold: /items/2?tab=b, params.id=2

Cause

The location and params are written to the event controller at the navigation transaction's commit, when the response arrives (src/browser/navigation-transaction.ts, commit()). Mounted readers pick the change up in the payload's batch, which React holds together with the destination tree. A reader that mounts in between initializes from the controller's current value, which is already the destination's.

useNavigation().location has the related property that it moves at the transaction's commit, ahead of a held tree.

Who hits it

A page that mounts something new while a navigation is pending (a virtualized row, a dialog, a late Suspense reveal) where that component reads the URL hooks or useParams. Narrow, and the window is only the time React holds the tree after the response arrived (a destination that suspends outside a Suspense boundary, a same-route hold, transition()).

Direction

Hold the pathname, search and params of the entry on screen next to its location state in NavigationProvider, read through context, the way #1033 did for location state. Then a value can only render with its entry's tree, for every reader. Estimated 150 to 250 lines of source (three hooks, useSegments, ssr-root.tsx, renderRoute), one context per value to keep the render-count pins, with 858 B of headroom under the client chunk limit. Not measured.

This would also be the place to settle item 1 of #1042 (a commit without a payload landing while another navigation is held), which reproduces: a revalidate: false push during a held push puts the shallow entry's URL and location state on the page being left within about 15 ms, for the rest of the hold.

Activity

  1. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    Assessment after an independent review: real, low value, parked. The expected-failure pin in navigation-url-commit.test.ts stays, so the suite turns red if this gets fixed by something else.

    What has to be true for a user to see it

    1. The navigation's commit is held: a transition() segment, a same-route navigation whose loaders are still streaming (the default hold), an action or stale revalidation, or a destination that suspends outside a Suspense boundary. A cross-route navigation that commits at once has no window.
    2. The response has started to arrive (the transaction has committed). Before that every reader holds the old URL.
    3. A component first MOUNTS in the page being left during that window and reads usePathname / useSearchParams / useParams / useSegments.

    The window can be most of the navigation (from the first byte until the slowest streamed loader settles), so it is not short. What is rare is condition 3. The realistic case: a product page to product page navigation, the user opens a drawer on the old product during the wait, and the drawer reads useParams().productId to build its action; it then acts on the destination product while the old one is shown.

    Same root cause, seen during #1049: a page mounted by one push while the next push had already committed showed the next entry's URL over its own content (1 of 6 dev runs with back-to-back navigations), and item 1 of #1042.

    If it is built

    The context design in the description is the only sound one. The alternatives were checked and rejected: hooks that initialize from a provider snapshot but keep the controller subscription stay stale after the commit (nothing notifies at React's commit); a useSyncExternalStore read of the controller's location makes mounted readers move at the transaction's commit, undoing #1049 for the held part; a "presented location" set from a layout effect costs an extra render and adds a second source.

    With one context per value (pathname, search, params) the hooks lose their subscription code, so the estimate is closer to 100 to 150 changed lines with a roughly flat bundle; not measured. It does not settle #1042 item 1 on its own: a shallow push during a held push is an urgent update and still lands on the page on screen. The simple rule there is to document that a shallow push's URL applies at once to whatever is on screen, which is today's behavior.

  2. ivogt commented on Oct 28, 2026

    @ivogt
    CollaboratorAuthor

    Closing as documented behaviour, by the owner's decision C on the hooks review (rango-t96). A component that first mounts while React holds a transition() page reads the destination's URL and params. Components already mounted keep the page on screen's values until it commits, and then every hook agrees. A fix would add a second path to each hook's value, and a render after paint on every fully prefetched click. The behaviour is pinned in packages/rangojs-router/src/testing/tests/render-route-late-reader-commit.test.tsx and documented in the hooks skill and execution-model.md ("Client Route-State Commits").

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions