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
1 change: 1 addition & 0 deletions docs/router/api/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ title: Router API
- [`useNavigate`](./router/useNavigateHook.md)
- [`useParentMatches`](./router/useParentMatchesHook.md)
- [`useParams`](./router/useParamsHook.md)
- [`usePendingMatches`](./router/usePendingMatchesHook.md)
- [`useRouteContext`](./router/useRouteContextHook.md)
- [`useRouter`](./router/useRouterHook.md)
- [`useRouterState`](./router/useRouterStateHook.md)
Expand Down
52 changes: 52 additions & 0 deletions docs/router/api/router/usePendingMatchesHook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
id: usePendingMatchesHook
title: usePendingMatches hook
---

The `usePendingMatches` hook returns the [`RouteMatch`](./RouteMatchType.md) objects for the location the router is currently navigating to. While a navigation is in flight — running `beforeLoad`, loading code-split chunks, or awaiting loaders — these are the matches for the destination location. Once the navigation resolves (or when no navigation is in flight), the array is empty and the resolved matches are available via [`useMatches`](./useMatchesHook.md).

This is useful for optimistic UI during navigation, e.g. highlighting the navigation item of the destination route from its `staticData` before its chunks and loaders have finished.

> [!TIP]
> If you want the currently rendered matches, use [`useMatches`](./useMatchesHook.md) instead.

## usePendingMatches options

The `usePendingMatches` hook accepts a single _optional_ argument, an `options` object.

### `opts.select` option

- Optional
- `(matches: RouteMatch[]) => TSelected`
- If supplied, this function will be called with the pending route matches and the return value will be returned from `usePendingMatches`. This value will also be used to determine if the hook should re-render its parent component using shallow equality checks.

### `opts.structuralSharing` option

- Type: `boolean`
- Optional
- Only supported by `@tanstack/react-router`.
- Configures whether structural sharing is enabled for the value returned by `select`.
- See the [Render Optimizations guide](../../guide/render-optimizations.md) for more information.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## usePendingMatches returns

- If a `select` function is provided, the return value of the `select` function.
- If no `select` function is provided, an array of [`RouteMatch`](./RouteMatchType.md) objects. The array is empty when no navigation is in flight.

## Examples

```tsx
import { useMatches, usePendingMatches } from '@tanstack/react-router'

function ActiveTab() {
const pendingTab = usePendingMatches({
select: (matches) => matches.findLast((m) => m.staticData.tab)?.staticData.tab,
})
const resolvedTab = useMatches({
select: (matches) => matches.findLast((m) => m.staticData.tab)?.staticData.tab,
})

const activeTab = pendingTab ?? resolvedTab
// ...
}
```
42 changes: 42 additions & 0 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,48 @@ export function useMatches<
) as UseMatchesResult<TRouter, TSelected>
}

/**
* Read the array of pending route matches or select a derived subset.
*
* Pending matches are populated while the router is loading the next
* location and are cleared once the navigation resolves, so the array is
* empty outside of an active navigation.
*
* Useful for optimistic UI during navigation, e.g. highlighting the target
* navigation item from the pending matches' `staticData` before the
* transition commits.
*
* @returns The array of pending matches (or the selected value).
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/usePendingMatchesHook
*/
export function usePendingMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
TStructuralSharing extends boolean = boolean,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected, TStructuralSharing> &
StructuralSharingOption<TRouter, TSelected, TStructuralSharing>,
): UseMatchesResult<TRouter, TSelected> {
const router = useRouter<TRouter>()

if (isServer ?? router.isServer) {
const matches = router.stores.pendingMatches.get() as Array<
MakeRouteMatchUnion<TRouter>
>
return (opts?.select ? opts.select(matches) : matches) as UseMatchesResult<
TRouter,
TSelected
>
}

// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static
return useStore(
router.stores.pendingMatches,
// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static
useStructuralSharing(opts, router),
) as UseMatchesResult<TRouter, TSelected>
}

/**
* Read the full array of active route matches or select a derived subset.
*
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export {
useMatchRoute,
MatchRoute,
useMatches,
usePendingMatches,
useParentMatches,
useChildMatches,
} from './Matches'
Expand Down
71 changes: 70 additions & 1 deletion packages/react-router/tests/Matches.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, test } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { createMemoryHistory } from '@tanstack/history'
import {
Link,
Expand All @@ -11,6 +18,7 @@ import {
isMatch,
useMatchRoute,
useMatches,
usePendingMatches,
} from '../src'

const rootRoute = createRootRoute()
Expand Down Expand Up @@ -354,3 +362,64 @@ describe('matching on different param types', () => {
},
)
})

describe('usePendingMatches', () => {
afterEach(() => cleanup())

test('exposes the destination matches while a navigation is loading', async () => {
let resolveLoader!: () => void
const loaderPromise = new Promise<void>((resolve) => {
resolveLoader = resolve
})

const RootComponent = () => {
const pendingPath = usePendingMatches({
select: (matches) => matches[matches.length - 1]?.pathname ?? '',
})
return (
<>
<div data-testid="pending-path">{pendingPath}</div>
<Outlet />
</>
)
}

const root = createRootRoute({
component: RootComponent,
})

const home = createRoute({
getParentRoute: () => root,
path: '/',
component: () => <Link to="/posts">To Posts</Link>,
})

const posts = createRoute({
getParentRoute: () => root,
path: 'posts',
loader: () => loaderPromise,
component: () => <div>Posts</div>,
})

const router = createRouter({
routeTree: root.addChildren([home, posts]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(<RouterProvider router={router} />)

fireEvent.click(await screen.findByText('To Posts'))

await waitFor(() =>
expect(screen.getByTestId('pending-path').textContent).toBe('/posts'),
)

await act(async () => {
resolveLoader()
await loaderPromise
})

await screen.findByText('Posts')
expect(screen.getByTestId('pending-path').textContent).toBe('')
})
})
23 changes: 22 additions & 1 deletion packages/solid-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,28 @@ export function useMatches<
MakeRouteMatchUnion<TRouter>
>
const res = opts?.select ? opts.select(matches) : matches
if (prev === undefined) return res
if (prev === undefined) {
return res
}
return replaceEqualDeep(prev, res) as any
}) as Solid.Accessor<UseMatchesResult<TRouter, TSelected>>
}

export function usePendingMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected>,
): Solid.Accessor<UseMatchesResult<TRouter, TSelected>> {
const router = useRouter<TRouter>()
return Solid.createMemo((prev: TSelected | undefined) => {
const matches = router.stores.pendingMatches.get() as Array<
MakeRouteMatchUnion<TRouter>
>
const res = opts?.select ? opts.select(matches) : matches
if (prev === undefined) {
return res
}
return replaceEqualDeep(prev, res) as any
}) as Solid.Accessor<UseMatchesResult<TRouter, TSelected>>
}
Expand Down
1 change: 1 addition & 0 deletions packages/solid-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export {
useMatchRoute,
MatchRoute,
useMatches,
usePendingMatches,
useParentMatches,
useChildMatches,
} from './Matches'
Expand Down
59 changes: 59 additions & 0 deletions packages/solid-router/tests/Matches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isMatch,
useMatchRoute,
useMatches,
usePendingMatches,
} from '../src'

const rootRoute = createRootRoute()
Expand Down Expand Up @@ -443,3 +444,61 @@ describe('matching on different param types', () => {
},
)
})

describe('usePendingMatches', () => {
afterEach(() => cleanup())

test('exposes the destination matches while a navigation is loading', async () => {
let resolveLoader!: () => void
const loaderPromise = new Promise<void>((resolve) => {
resolveLoader = resolve
})

const root = createRootRoute({
component: () => {
const pendingPath = usePendingMatches({
select: (matches) => matches[matches.length - 1]?.pathname ?? '',
})
return (
<>
<div data-testid="pending-path">{pendingPath()}</div>
<Outlet />
</>
)
},
})

const home = createRoute({
getParentRoute: () => root,
path: '/',
component: () => <Link to="/posts">To Posts</Link>,
})

const posts = createRoute({
getParentRoute: () => root,
path: 'posts',
loader: () => loaderPromise,
component: () => <div>Posts</div>,
})

const router = createRouter({
routeTree: root.addChildren([home, posts]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(() => <RouterProvider router={router} />)

fireEvent.click(await screen.findByText('To Posts'))

await waitFor(() =>
expect(screen.getByTestId('pending-path').textContent).toBe('/posts'),
)

resolveLoader()

await screen.findByText('Posts')
await waitFor(() =>
expect(screen.getByTestId('pending-path').textContent).toBe(''),
)
})
})
14 changes: 14 additions & 0 deletions packages/vue-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,20 @@ export function useMatches<
})
}

export function usePendingMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected>,
): Vue.Ref<UseMatchesResult<TRouter, TSelected>> {
const router = useRouter<TRouter>()
return useStore(router.stores.pendingMatches, (matches) => {
return opts?.select
? opts.select(matches as Array<MakeRouteMatchUnion<TRouter>>)
: (matches as any)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function useParentMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
Expand Down
1 change: 1 addition & 0 deletions packages/vue-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export {
useMatchRoute,
MatchRoute,
useMatches,
usePendingMatches,
useParentMatches,
useChildMatches,
} from './Matches'
Expand Down
Loading