From d48b08ae95c268b80178ee2bc3a19502390c8401 Mon Sep 17 00:00:00 2001 From: Thomas Seljen Tvedt Date: Mon, 20 Jul 2026 21:48:16 +0200 Subject: [PATCH 1/3] feat: add usePendingMatches hook Expose the router's pending matches reactively in the react, solid and vue adapters, mirroring useMatches over router.stores.pendingMatches. pendingMatches was selectable via useRouterState before the granular stores refactor; the store still exists but had no public reactive accessor. Closes #7859 Claude-Session: https://claude.ai/code/session_0194gnPcL2Vh2fiESjL4YdKZ --- docs/router/api/router.md | 1 + .../api/router/usePendingMatchesHook.md | 51 +++++++++++++ packages/react-router/src/Matches.tsx | 42 +++++++++++ packages/react-router/src/index.tsx | 1 + packages/react-router/tests/Matches.test.tsx | 71 ++++++++++++++++++- packages/solid-router/src/Matches.tsx | 17 +++++ packages/solid-router/src/index.tsx | 1 + packages/solid-router/tests/Matches.test.tsx | 59 +++++++++++++++ packages/vue-router/src/Matches.tsx | 14 ++++ packages/vue-router/src/index.tsx | 1 + packages/vue-router/tests/Matches.test.tsx | 62 ++++++++++++++++ 11 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 docs/router/api/router/usePendingMatchesHook.md diff --git a/docs/router/api/router.md b/docs/router/api/router.md index 2c5ede186c..951eb6f610 100644 --- a/docs/router/api/router.md +++ b/docs/router/api/router.md @@ -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) diff --git a/docs/router/api/router/usePendingMatchesHook.md b/docs/router/api/router/usePendingMatchesHook.md new file mode 100644 index 0000000000..20f371e0eb --- /dev/null +++ b/docs/router/api/router/usePendingMatchesHook.md @@ -0,0 +1,51 @@ +--- +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 loading, 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 +- Configures whether structural sharing is enabled for the value returned by `select`. +- See the [Render Optimizations guide](../../guide/render-optimizations.md) for more information. + +## 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 + // ... +} +``` diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 62d0fc42ae..9ff4cc8150 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -254,6 +254,48 @@ export function useMatches< ) as UseMatchesResult } +/** + * 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 & + StructuralSharingOption, +): UseMatchesResult { + const router = useRouter() + + if (isServer ?? router.isServer) { + const matches = router.stores.pendingMatches.get() as Array< + MakeRouteMatchUnion + > + 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 +} + /** * Read the full array of active route matches or select a derived subset. * diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 6637c7542b..b3371bd56a 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -232,6 +232,7 @@ export { useMatchRoute, MatchRoute, useMatches, + usePendingMatches, useParentMatches, useChildMatches, } from './Matches' diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index dd3ca3dfa6..b83e171b09 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -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, @@ -11,6 +18,7 @@ import { isMatch, useMatchRoute, useMatches, + usePendingMatches, } from '../src' const rootRoute = createRootRoute() @@ -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((resolve) => { + resolveLoader = resolve + }) + + const RootComponent = () => { + const pendingPath = usePendingMatches({ + select: (matches) => matches[matches.length - 1]?.pathname ?? '', + }) + return ( + <> +
{pendingPath}
+ + + ) + } + + const root = createRootRoute({ + component: RootComponent, + }) + + const home = createRoute({ + getParentRoute: () => root, + path: '/', + component: () => To Posts, + }) + + const posts = createRoute({ + getParentRoute: () => root, + path: 'posts', + loader: () => loaderPromise, + component: () =>
Posts
, + }) + + const router = createRouter({ + routeTree: root.addChildren([home, posts]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + 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('') + }) +}) diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index 75586f862d..0569e76413 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -218,6 +218,23 @@ export function useMatches< }) as Solid.Accessor> } +export function usePendingMatches< + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseMatchesBaseOptions, +): Solid.Accessor> { + const router = useRouter() + return Solid.createMemo((prev: TSelected | undefined) => { + const matches = router.stores.pendingMatches.get() as Array< + MakeRouteMatchUnion + > + const res = opts?.select ? opts.select(matches) : matches + if (prev === undefined) return res + return replaceEqualDeep(prev, res) as any + }) as Solid.Accessor> +} + export function useParentMatches< TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 31be74086f..a47186800f 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -236,6 +236,7 @@ export { useMatchRoute, MatchRoute, useMatches, + usePendingMatches, useParentMatches, useChildMatches, } from './Matches' diff --git a/packages/solid-router/tests/Matches.test.tsx b/packages/solid-router/tests/Matches.test.tsx index 2685500fd0..30f196159f 100644 --- a/packages/solid-router/tests/Matches.test.tsx +++ b/packages/solid-router/tests/Matches.test.tsx @@ -19,6 +19,7 @@ import { isMatch, useMatchRoute, useMatches, + usePendingMatches, } from '../src' const rootRoute = createRootRoute() @@ -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((resolve) => { + resolveLoader = resolve + }) + + const root = createRootRoute({ + component: () => { + const pendingPath = usePendingMatches({ + select: (matches) => matches[matches.length - 1]?.pathname ?? '', + }) + return ( + <> +
{pendingPath()}
+ + + ) + }, + }) + + const home = createRoute({ + getParentRoute: () => root, + path: '/', + component: () => To Posts, + }) + + const posts = createRoute({ + getParentRoute: () => root, + path: 'posts', + loader: () => loaderPromise, + component: () =>
Posts
, + }) + + const router = createRouter({ + routeTree: root.addChildren([home, posts]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + + 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(''), + ) + }) +}) diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index da8220db29..a06feee0fb 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -300,6 +300,20 @@ export function useMatches< }) } +export function usePendingMatches< + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseMatchesBaseOptions, +): Vue.Ref> { + const router = useRouter() + return useStore(router.stores.pendingMatches, (matches) => { + return opts?.select + ? opts.select(matches as Array>) + : (matches as any) + }) +} + export function useParentMatches< TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 2e14e349f0..67eac6866f 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -228,6 +228,7 @@ export { useMatchRoute, MatchRoute, useMatches, + usePendingMatches, useParentMatches, useChildMatches, } from './Matches' diff --git a/packages/vue-router/tests/Matches.test.tsx b/packages/vue-router/tests/Matches.test.tsx index 4e9e9afa51..73003664d4 100644 --- a/packages/vue-router/tests/Matches.test.tsx +++ b/packages/vue-router/tests/Matches.test.tsx @@ -17,6 +17,7 @@ import { isMatch, useMatchRoute, useMatches, + usePendingMatches, } from '../src' const rootRoute = createRootRoute() @@ -371,3 +372,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((resolve) => { + resolveLoader = resolve + }) + + const PendingPath = () => { + const pendingPath = usePendingMatches({ + select: (matches: Array) => + matches[matches.length - 1]?.pathname ?? '', + }) + return ( + <> +
{pendingPath.value}
+ + + ) + } + + const root = createRootRoute({ + component: PendingPath, + }) + + const home = createRoute({ + getParentRoute: () => root, + path: '/', + component: () => To Posts, + }) + + const posts = createRoute({ + getParentRoute: () => root, + path: 'posts', + loader: () => loaderPromise, + component: () =>
Posts
, + }) + + const router = createRouter({ + routeTree: root.addChildren([home, posts]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + 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(''), + ) + }) +}) From 357e0ce807bfac29243017f0020f15324fdad846 Mon Sep 17 00:00:00 2001 From: Thomas Seljen Tvedt Date: Mon, 20 Jul 2026 22:00:33 +0200 Subject: [PATCH 2/3] docs: clarify usePendingMatches in-flight semantics and React-only structuralSharing Also rely on type inference for the select callback in the vue test. Claude-Session: https://claude.ai/code/session_0194gnPcL2Vh2fiESjL4YdKZ --- docs/router/api/router/usePendingMatchesHook.md | 3 ++- packages/vue-router/tests/Matches.test.tsx | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/router/api/router/usePendingMatchesHook.md b/docs/router/api/router/usePendingMatchesHook.md index 20f371e0eb..e97fe69b60 100644 --- a/docs/router/api/router/usePendingMatchesHook.md +++ b/docs/router/api/router/usePendingMatchesHook.md @@ -3,7 +3,7 @@ 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 loading, 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). +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. @@ -24,6 +24,7 @@ The `usePendingMatches` hook accepts a single _optional_ argument, an `options` - 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. diff --git a/packages/vue-router/tests/Matches.test.tsx b/packages/vue-router/tests/Matches.test.tsx index 73003664d4..d389f0f2ff 100644 --- a/packages/vue-router/tests/Matches.test.tsx +++ b/packages/vue-router/tests/Matches.test.tsx @@ -384,8 +384,7 @@ describe('usePendingMatches', () => { const PendingPath = () => { const pendingPath = usePendingMatches({ - select: (matches: Array) => - matches[matches.length - 1]?.pathname ?? '', + select: (matches) => matches[matches.length - 1]?.pathname ?? '', }) return ( <> From 81cf241edff8093fed9bf9a3b8dbd4d344c32836 Mon Sep 17 00:00:00 2001 From: Thomas Seljen Tvedt Date: Mon, 27 Jul 2026 22:21:38 +0200 Subject: [PATCH 3/3] style: use curly braces in solid useMatches/usePendingMatches Claude-Session: https://claude.ai/code/session_014tmycU8VTed8XzxxHsinNM --- packages/solid-router/src/Matches.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index 0569e76413..aecb9a1c76 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -213,7 +213,9 @@ export function useMatches< MakeRouteMatchUnion > 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> } @@ -230,7 +232,9 @@ export function usePendingMatches< MakeRouteMatchUnion > 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> }