From 962cf68654d7d21c81e9c391cdfa9e5db04ea1a9 Mon Sep 17 00:00:00 2001 From: Ulrich Stark <8657779+ulrichstark@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:55:41 +0200 Subject: [PATCH] fix(router): keep useLocation on the rendered route's location --- .changeset/use-location-presented-lane.md | 14 ++++++ docs/router/api/router/useLocationHook.md | 2 + packages/react-router/src/useLocation.tsx | 4 +- ...-8037-use-location-stale-pathname.test.tsx | 50 +++++++++++++++++++ packages/router-core/src/load-client.ts | 31 +++++++++--- packages/router-core/src/load-server.ts | 3 +- packages/router-core/src/router.ts | 2 + packages/router-core/src/stores.ts | 27 +++++++++- packages/solid-router/src/useLocation.tsx | 4 +- packages/vue-router/src/useLocation.tsx | 2 +- 10 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 .changeset/use-location-presented-lane.md create mode 100644 packages/react-router/tests/issue-8037-use-location-stale-pathname.test.tsx diff --git a/.changeset/use-location-presented-lane.md b/.changeset/use-location-presented-lane.md new file mode 100644 index 00000000000..91ac925c598 --- /dev/null +++ b/.changeset/use-location-presented-lane.md @@ -0,0 +1,14 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +`useLocation` now returns the location that produced the matches currently +being rendered instead of the location the router has parsed but not yet +loaded. A component on the route being left no longer re-renders with the +destination's pathname before it unmounts. The presented location is +published on the same lane as the matches it describes, so the two can never +disagree. `router.state.location` is unchanged and still reports the +requested location while it loads. diff --git a/docs/router/api/router/useLocationHook.md b/docs/router/api/router/useLocationHook.md index 7bbccad150a..3d05158f660 100644 --- a/docs/router/api/router/useLocationHook.md +++ b/docs/router/api/router/useLocationHook.md @@ -5,6 +5,8 @@ title: useLocation hook The `useLocation` method is a hook that returns the current [`location`](./ParsedLocationType.md) object. This hook is useful for when you want to perform some side effect whenever the current location changes. +The returned location is the one that produced the matches currently being rendered, so a component only ever observes the location of the route it is rendered on. It changes together with the presentation, not when a navigation to somewhere else begins. To observe the requested location while it is still loading, read [`router.state.location`](./RouterStateType.md#location-property) instead. + ## useLocation options The `useLocation` hook accepts an optional `options` object. diff --git a/packages/react-router/src/useLocation.tsx b/packages/react-router/src/useLocation.tsx index 5c8c7c8d7e6..983cf31a9dd 100644 --- a/packages/react-router/src/useLocation.tsx +++ b/packages/react-router/src/useLocation.tsx @@ -53,7 +53,7 @@ export function useLocation< const router = useRouter() if (isServer ?? router.isServer) { - const location = router.stores.location.get() + const location = router.stores.presentedLocation.get() return ( opts?.select ? opts.select(location as any) : location ) as UseLocationResult @@ -61,7 +61,7 @@ export function useLocation< // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static return useStore( - router.stores.location, + router.stores.presentedLocation, // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static useStructuralSharing(opts, router), ) as UseLocationResult diff --git a/packages/react-router/tests/issue-8037-use-location-stale-pathname.test.tsx b/packages/react-router/tests/issue-8037-use-location-stale-pathname.test.tsx new file mode 100644 index 00000000000..634ab7aa0ce --- /dev/null +++ b/packages/react-router/tests/issue-8037-use-location-stale-pathname.test.tsx @@ -0,0 +1,50 @@ +import { act } from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useLocation, +} from '../src' + +afterEach(() => { + cleanup() +}) + +// https://github.com/TanStack/router/issues/8037 +test('#8037: useLocation in a route component does not report the pathname it is navigating to', async () => { + const seen: Array = [] + + function Probe() { + seen.push(useLocation().pathname) + return
Posts
+ } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: Probe, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/posts'] }), + }) + + render() + expect(await screen.findByText('Posts')).toBeInTheDocument() + + await act(() => router.navigate({ to: '/' })) + expect(await screen.findByText('Home')).toBeInTheDocument() + + expect(seen).not.toContain('/') +}) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index f0024720b8c..8ec31d321a0 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -234,6 +234,7 @@ type CoordinatorRouter = AnyRouter & { type PublicationCheckpoint = { previousMatches: Array previousPresentation: Array + previousPresentedLocation: ParsedLocation previousCache: Map commitPromise: CoordinatorRouter['_commitPromise'] published: boolean @@ -1480,7 +1481,10 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { })) offered[boundary]!.status = 'pending' const ack = router - .startTransition(() => router.stores.setMatches(offered), offered) + .startTransition( + () => router.stores.setMatches(offered, tx[2 /* location */]), + offered, + ) .then((rendered) => { if ( rendered && @@ -1510,9 +1514,11 @@ function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void { function publishMatches( router: CoordinatorRouter, matches: Array, + location: ParsedLocation, ): void { router._committed = matches - router.stores.setMatches(matches) + router._committedLocation = location + router.stores.setMatches(matches, location) } function discardLane(router: AnyRouter, lane: ProjectedLane): void { @@ -1580,7 +1586,7 @@ function commitMatches( // The lane becomes committed before publication can synchronously reenter. tx[3 /* matches */] = [] router._cache = cached - publishMatches(router, matches) + publishMatches(router, matches, tx[2 /* location */]) transferMatchResources( router, [...previousCached.values(), ...previous], @@ -1608,7 +1614,7 @@ function commitRefreshMatches( checkpoint.previousMatches = previous checkpoint.previousCache = previousCached checkpoint.published = true - publishMatches(router, matches) + publishMatches(router, matches, tx[2 /* location */]) if (!checkpoint.published || router._tx !== tx) { return } @@ -1652,6 +1658,7 @@ function rollbackPublication( ] router._cache = checkpoint.previousCache router._committed = checkpoint.previousMatches + router._committedLocation = checkpoint.previousPresentedLocation checkpoint.published = false for (const match of discarded as Array) { @@ -1667,7 +1674,10 @@ function rollbackPublication( finishPending(router, tx) router.batch(() => { router.stores.status.set('idle') - router.stores.setMatches(checkpoint.previousPresentation) + router.stores.setMatches( + checkpoint.previousPresentation, + checkpoint.previousPresentedLocation, + ) }) tx[0 /* controller */].abort() transferMatchResources(router, discarded, restored) @@ -1689,6 +1699,7 @@ async function transitionRefresh( const checkpoint: PublicationCheckpoint = { previousMatches: router._committed, previousPresentation: refresh[0 /* presentation */], + previousPresentedLocation: router.stores.presentedLocation.get(), previousCache: router._cache, commitPromise: router._commitPromise, published: false, @@ -1777,7 +1788,10 @@ function restoreCommitted( } router.batch(() => { router.stores.status.set('idle') - router.stores.setMatches(router._committed) + router.stores.setMatches( + router._committed, + router._committedLocation ?? router.stores.location.get(), + ) }) if (router._tx === tx) { router._commitPromise?.resolve() @@ -1842,7 +1856,7 @@ async function runBackground( releaseFlight(router, cached) } } - publishMatches(router, projected[1 /* matches */]) + publishMatches(router, projected[1 /* matches */], tx[2 /* location */]) transferMatchResources(router, base, projected[1 /* matches */]) } @@ -2629,10 +2643,11 @@ export async function hydrate(router: AnyRouter): Promise { }, ] router._committed = committedMatches + router._committedLocation = location router._handoff = handoff router._preflight = undefined router.batch(() => { - router.stores.setMatches(presented) + router.stores.setMatches(presented, location) router.stores.status.set('idle') if (!needsClientLoad) { router.stores.resolvedLocation.set(router.stores.location.get()) diff --git a/packages/router-core/src/load-server.ts b/packages/router-core/src/load-server.ts index 083d7ffb407..35177418710 100644 --- a/packages/router-core/src/load-server.ts +++ b/packages/router-core/src/load-server.ts @@ -836,12 +836,13 @@ export async function loadServerRoute( router.stores.location.set(next) router.stores.status.set('idle') if (result.type === 'render') { - router.stores.setMatches(result.matches) + router.stores.setMatches(result.matches, next) router.stores.resolvedLocation.set(next) } }) if (result.type === 'render') { router._committed = result.matches + router._committedLocation = next runRouteLifecycle(router, previous, result.matches) } router._commitPromise?.resolve() diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index cc83e358de2..c659b9d0406 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1083,6 +1083,8 @@ export class RouterCore< _cache = new Map() /** Accepted semantic lane, excluding temporary pending presentation. */ _committed: Array = [] + /** The location that produced `_committed`. */ + _committedLocation?: ParsedLocation // Must build in constructor stores!: RouterStores diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index 8c2beeb2e72..1f8201703e9 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -67,6 +67,14 @@ export function createNonReactiveReadonlyStore( export interface RouterStores { status: RouterWritableStore['status']> location: RouterWritableStore>> + /** + * The location that produced the match presentation currently exposed to the + * application. It travels on the presentation lane, so observers of a leaving + * route never see the location of the route being navigated to. + */ + presentedLocation: RouterReadableStore< + ParsedLocation> + > resolvedLocation: RouterWritableStore< ParsedLocation> | undefined > @@ -84,7 +92,10 @@ export interface RouterStores { routeId: string, ) => RouterReadableStore - setMatches: (nextMatches: Array) => void + setMatches: ( + nextMatches: Array, + location: ParsedLocation>, + ) => void } export function createRouterStores( @@ -99,6 +110,7 @@ export function createRouterStores( // atoms const status = createMutableStore['status']>('idle') const location = createMutableStore(initialLocation) + const presentedLocation = createMutableStore(initialLocation) const resolvedLocation = createMutableStore['resolvedLocation']>(undefined) const ids = createMutableStore>([]) @@ -130,6 +142,7 @@ export function createRouterStores( // atoms status, location, + presentedLocation, resolvedLocation, ids, @@ -150,7 +163,10 @@ export function createRouterStores( } // setters to update non-reactive utilities in sync with the reactive stores - function setMatches(nextMatches: Array) { + function setMatches( + nextMatches: Array, + nextLocation: RouterState['location'], + ) { const previousIds = ids.get() const nextIds = nextMatches.map((match) => match.routeId) @@ -161,6 +177,13 @@ export function createRouterStores( ids.set(nextIds) } + // The presented location rides the same publication as the lane it + // describes. A route that is leaving is reconciled away by the lane + // change above, so it never re-renders with the destination's location. + if (presentedLocation.get() !== nextLocation) { + presentedLocation.set(nextLocation) + } + for (const id of previousIds) { if (!nextIds.includes(id)) { byRoute.get(id)!.set(() => undefined) diff --git a/packages/solid-router/src/useLocation.tsx b/packages/solid-router/src/useLocation.tsx index 5c968443bba..dbc691193e6 100644 --- a/packages/solid-router/src/useLocation.tsx +++ b/packages/solid-router/src/useLocation.tsx @@ -28,7 +28,7 @@ export function useLocation< const router = useRouter() if (!opts?.select) { - return (() => router.stores.location.get()) as Accessor< + return (() => router.stores.presentedLocation.get()) as Accessor< UseLocationResult > } @@ -36,7 +36,7 @@ export function useLocation< const select = opts.select return Solid.createMemo((prev: TSelected | undefined) => { - const res = select(router.stores.location.get()) + const res = select(router.stores.presentedLocation.get()) if (prev === undefined) return res return replaceEqualDeep(prev, res) }) as Accessor> diff --git a/packages/vue-router/src/useLocation.tsx b/packages/vue-router/src/useLocation.tsx index 2d9e4314a24..9b5c28a8044 100644 --- a/packages/vue-router/src/useLocation.tsx +++ b/packages/vue-router/src/useLocation.tsx @@ -25,7 +25,7 @@ export function useLocation< opts?: UseLocationBaseOptions, ): Vue.Ref> { const router = useRouter() - return useStore(router.stores.location, (location) => + return useStore(router.stores.presentedLocation, (location) => opts?.select ? opts.select(location) : location, ) as Vue.Ref> }