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..e97fe69b60 --- /dev/null +++ b/docs/router/api/router/usePendingMatchesHook.md @@ -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. + +## 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..aecb9a1c76 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -213,7 +213,28 @@ 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> +} + +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> } 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..d389f0f2ff 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,63 @@ 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) => 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(''), + ) + }) +})