Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/use-location-presented-lane.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/router/api/router/useLocationHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/src/useLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ export function useLocation<
const router = useRouter<TRouter>()

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<TRouter, TSelected>
}

// 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<TRouter, TSelected>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> = []

function Probe() {
seen.push(useLocation().pathname)
return <div>Posts</div>
}

const rootRoute = createRootRoute({ component: () => <Outlet /> })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>Home</div>,
})
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
component: Probe,
})
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history: createMemoryHistory({ initialEntries: ['/posts'] }),
})

render(<RouterProvider router={router} />)
expect(await screen.findByText('Posts')).toBeInTheDocument()

await act(() => router.navigate({ to: '/' }))
expect(await screen.findByText('Home')).toBeInTheDocument()

expect(seen).not.toContain('/')
})
31 changes: 23 additions & 8 deletions packages/router-core/src/load-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ type CoordinatorRouter = AnyRouter & {
type PublicationCheckpoint = {
previousMatches: Array<AnyRouteMatch>
previousPresentation: Array<AnyRouteMatch>
previousPresentedLocation: ParsedLocation
previousCache: Map<string, AnyRouteMatch>
commitPromise: CoordinatorRouter['_commitPromise']
published: boolean
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -1510,9 +1514,11 @@ function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void {
function publishMatches(
router: CoordinatorRouter,
matches: Array<AnyRouteMatch>,
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 {
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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<WorkMatch>) {
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 */])
}

Expand Down Expand Up @@ -2629,10 +2643,11 @@ export async function hydrate(router: AnyRouter): Promise<void> {
},
]
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())
Expand Down
3 changes: 2 additions & 1 deletion packages/router-core/src/load-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,8 @@ export class RouterCore<
_cache = new Map<string, AnyRouteMatch>()
/** Accepted semantic lane, excluding temporary pending presentation. */
_committed: Array<AnyRouteMatch> = []
/** The location that produced `_committed`. */
_committedLocation?: ParsedLocation
Comment on lines +1086 to +1087

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file outline ---'
ast-grep outline packages/router-core/src/router.ts | head -120

printf '%s\n' '--- relevant declarations and usages ---'
rg -n -C 4 'ParsedLocation|FullSearchSchema|_committedLocation|latestLocation' packages/router-core/src/router.ts packages/router-core/src

Repository: TanStack/router

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- location and schema types ---'
cat -n packages/router-core/src/location.ts | sed -n '1,70p'
rg -n -C 3 'export type AnySchema|type AnySchema' packages/router-core/src

printf '%s\n' '--- RouterCore declaration and fields ---'
cat -n packages/router-core/src/router.ts | sed -n '1000,1125p'

printf '%s\n' '--- committed-location signatures and calls ---'
rg -n -C 8 '_committedLocation|function publishMatches|publishMatches\(' packages/router-core/src/load-client.ts packages/router-core/src/router.ts

Repository: TanStack/router

Length of output: 18632


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- load-client type aliases and location-bearing types ---'
rg -n -C 10 'type CoordinatorRouter|interface CoordinatorRouter|type LoadTransaction|type ProjectedLane|type PublicationCheckpoint|previousPresentedLocation|function publishMatches' packages/router-core/src/load-client.ts

printf '%s\n' '--- location-producing declarations passed to publication ---'
sed -n '145,245p' packages/router-core/src/load-client.ts
sed -n '1565,1630p' packages/router-core/src/load-client.ts
sed -n '2620,2660p' packages/router-core/src/load-client.ts

printf '%s\n' '--- compiler availability ---'
if command -v tsc >/dev/null 2>&1; then
  tsc --version
else
  printf '%s\n' 'tsc unavailable'
fi

Repository: TanStack/router

Length of output: 12429


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- AnyRouter and route-info aliases ---'
rg -n -C 8 'export type AnyRouter|type AnyRouter|export type FullSearchSchema|type FullSearchSchema|type ParseRoute|PartialMergeAll' packages/router-core/src/router.ts packages/router-core/src/routeInfo.ts packages/router-core/src

printf '%s\n' '--- strict assignability probe ---'
cat > /tmp/router-location-type-probe.ts <<'TS'
type AnySchema = {}
interface ParsedLocation<TSearchObj extends AnySchema = {}> {
  search: TSearchObj
  maskedLocation?: ParsedLocation<TSearchObj>
}

type RequiredSearch = { required: string }
declare const bare: ParsedLocation
declare const typed: ParsedLocation<RequiredSearch>

const typedFromBare: ParsedLocation<RequiredSearch> = bare
const bareFromTyped: ParsedLocation = typed

interface Router<TRouteTree> {
  committed?: ParsedLocation<TRouteTree>
}
declare const router: Router<RequiredSearch>
router.committed = bare
TS

tsc --strict --noEmit --skipLibCheck /tmp/router-location-type-probe.ts 2>&1 || true

Repository: TanStack/router

Length of output: 17099


🏁 Script executed:

#!/bin/bash
set -eu

cat > /tmp/router-location-type-probe.ts <<'TS'
type AnySchema = {}

interface ParsedLocation<TSearchObj extends AnySchema = {}> {
  search: TSearchObj
  maskedLocation?: ParsedLocation<TSearchObj>
}

type RequiredSearch = { required: string }
type OptionalSearch = { optional?: string }

declare const bare: ParsedLocation
declare const requiredTyped: ParsedLocation<RequiredSearch>
declare const optionalTyped: ParsedLocation<OptionalSearch>

const requiredFromBare: ParsedLocation<RequiredSearch> = bare
const optionalFromBare: ParsedLocation<OptionalSearch> = bare
const bareFromRequired: ParsedLocation = requiredTyped
const bareFromOptional: ParsedLocation = optionalTyped

interface Router<TRouteTree> {
  committed?: ParsedLocation<TRouteTree>
}
declare const requiredRouter: Router<RequiredSearch>
declare const optionalRouter: Router<OptionalSearch>
requiredRouter.committed = bare
optionalRouter.committed = bare
TS

tsc --ignoreConfig --strict --noEmit --skipLibCheck /tmp/router-location-type-probe.ts 2>&1 || true

Repository: TanStack/router

Length of output: 751


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- search-schema merge helper ---'
sed -n '95,132p' packages/router-core/src/utils.ts

printf '%s\n' '--- optional route-schema probe ---'
cat > /tmp/router-location-optional-schema-probe.ts <<'TS'
type AnySchema = {}
interface ParsedLocation<TSearchObj extends AnySchema = {}> {
  search: TSearchObj
}

type FullSearchSchema = { page?: number; filter?: string }
declare const bare: ParsedLocation
declare const typed: ParsedLocation<FullSearchSchema>

const page: number | undefined = typed.search.page
const filter: string | undefined = typed.search.filter
const bareFromTyped: ParsedLocation = typed

// These must fail: the bare default loses the route-specific keys.
const missingPage: number | undefined = bare.search.page
const missingFilter: string | undefined = bare.search.filter
TS

tsc --ignoreConfig --strict --noEmit --skipLibCheck /tmp/router-location-optional-schema-probe.ts 2>&1 || true

Repository: TanStack/router

Length of output: 1346


Preserve the route search schema in _committedLocation.

ParsedLocation defaults its search type to {}, which removes route-specific search keys and value types. Use ParsedLocation<FullSearchSchema<TRouteTree>>, consistent with latestLocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router-core/src/router.ts` around lines 1086 - 1087, Update the
_committedLocation property to use ParsedLocation<FullSearchSchema<TRouteTree>>
instead of the default ParsedLocation type, matching latestLocation so
route-specific search keys and value types are preserved.

Source: Coding guidelines


// Must build in constructor
stores!: RouterStores<TRouteTree>
Expand Down
27 changes: 25 additions & 2 deletions packages/router-core/src/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ export function createNonReactiveReadonlyStore<TValue>(
export interface RouterStores<in out TRouteTree extends AnyRoute> {
status: RouterWritableStore<RouterState<TRouteTree>['status']>
location: RouterWritableStore<ParsedLocation<FullSearchSchema<TRouteTree>>>
/**
* 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<FullSearchSchema<TRouteTree>>
>
resolvedLocation: RouterWritableStore<
ParsedLocation<FullSearchSchema<TRouteTree>> | undefined
>
Expand All @@ -84,7 +92,10 @@ export interface RouterStores<in out TRouteTree extends AnyRoute> {
routeId: string,
) => RouterReadableStore<AnyRouteMatch | undefined>

setMatches: (nextMatches: Array<AnyRouteMatch>) => void
setMatches: (
nextMatches: Array<AnyRouteMatch>,
location: ParsedLocation<FullSearchSchema<TRouteTree>>,
) => void
}

export function createRouterStores<TRouteTree extends AnyRoute>(
Expand All @@ -99,6 +110,7 @@ export function createRouterStores<TRouteTree extends AnyRoute>(
// atoms
const status = createMutableStore<RouterState<TRouteTree>['status']>('idle')
const location = createMutableStore(initialLocation)
const presentedLocation = createMutableStore(initialLocation)
const resolvedLocation =
createMutableStore<RouterState<TRouteTree>['resolvedLocation']>(undefined)
const ids = createMutableStore<Array<string>>([])
Expand Down Expand Up @@ -130,6 +142,7 @@ export function createRouterStores<TRouteTree extends AnyRoute>(
// atoms
status,
location,
presentedLocation,
resolvedLocation,
ids,

Expand All @@ -150,7 +163,10 @@ export function createRouterStores<TRouteTree extends AnyRoute>(
}

// setters to update non-reactive utilities in sync with the reactive stores
function setMatches(nextMatches: Array<AnyRouteMatch>) {
function setMatches(
nextMatches: Array<AnyRouteMatch>,
nextLocation: RouterState<TRouteTree>['location'],
) {
const previousIds = ids.get()
const nextIds = nextMatches.map((match) => match.routeId)

Expand All @@ -161,6 +177,13 @@ export function createRouterStores<TRouteTree extends AnyRoute>(
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)
Expand Down
4 changes: 2 additions & 2 deletions packages/solid-router/src/useLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ export function useLocation<
const router = useRouter<TRouter>()

if (!opts?.select) {
return (() => router.stores.location.get()) as Accessor<
return (() => router.stores.presentedLocation.get()) as Accessor<
UseLocationResult<TRouter, TSelected>
>
}

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)
Comment on lines 30 to 41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add braces to both if statements.

Line 30 and line 40 use unbraced control bodies. Add braces while changing this function.

Proposed fix
-  if (!opts?.select) {
+  if (!opts?.select) {
     return (() => router.stores.presentedLocation.get()) as Accessor<
       UseLocationResult<TRouter, TSelected>
     >
   }
@@
-    if (prev === undefined) return res
+    if (prev === undefined) {
+      return res
+    }

As per coding guidelines, **/*.{ts,tsx} requires braces for every control statement. Based on learnings, changed Solid adapter code must fix directly related unbraced branches.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!opts?.select) {
return (() => router.stores.location.get()) as Accessor<
return (() => router.stores.presentedLocation.get()) as Accessor<
UseLocationResult<TRouter, TSelected>
>
}
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)
if (!opts?.select) {
return (() => router.stores.presentedLocation.get()) as Accessor<
UseLocationResult<TRouter, TSelected>
>
}
const select = opts.select
return Solid.createMemo((prev: TSelected | undefined) => {
const res = select(router.stores.presentedLocation.get())
if (prev === undefined) {
return res
}
return replaceEqualDeep(prev, res)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/solid-router/src/useLocation.tsx` around lines 30 - 41, Add braces
to both unbraced if statements in the useLocation flow: the opts?.select guard
and the prev === undefined check inside Solid.createMemo. Preserve their
existing return behavior and do not change surrounding selection logic.

Sources: Coding guidelines, Learnings

}) as Accessor<UseLocationResult<TRouter, TSelected>>
Expand Down
2 changes: 1 addition & 1 deletion packages/vue-router/src/useLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function useLocation<
opts?: UseLocationBaseOptions<TRouter, TSelected>,
): Vue.Ref<UseLocationResult<TRouter, TSelected>> {
const router = useRouter<TRouter>()
return useStore(router.stores.location, (location) =>
return useStore(router.stores.presentedLocation, (location) =>
opts?.select ? opts.select(location) : location,
) as Vue.Ref<UseLocationResult<TRouter, TSelected>>
}
Loading