diff --git a/docs/router/api/router/useMatchRouteHook.md b/docs/router/api/router/useMatchRouteHook.md index 01c26d07c6..e67ccb6cbd 100644 --- a/docs/router/api/router/useMatchRouteHook.md +++ b/docs/router/api/router/useMatchRouteHook.md @@ -3,7 +3,9 @@ id: useMatchRouteHook title: useMatchRoute hook --- -The `useMatchRoute` hook is a hook that returns a `matchRoute` function that can be used to match a route against either the current or pending location. +The `useMatchRoute` hook returns a `matchRoute` function that can be used to match a route against either the current or pending location. The hook subscribes the component to changes in the router state used for matching, making it useful when the match result affects what the component renders. + +The `matchRoute` function's identity changes when that router state changes. For an imperative check, such as one performed in an event handler, use [`useRouter`](./useRouterHook.md) and call `router.matchRoute` instead. The router instance is stable, and this avoids subscribing the component to router state that it does not use while rendering. ## useMatchRoute returns @@ -25,6 +27,8 @@ The `matchRoute` function accepts a single argument, an `options` object. ## Examples +Use `useMatchRoute` when the result determines the rendered output: + ```tsx import { useMatchRoute } from '@tanstack/react-router' @@ -33,7 +37,36 @@ function Component() { const matchRoute = useMatchRoute() const params = matchRoute({ to: '/posts/$postId' }) // ^ { postId: '123' } + + return params ? : } +``` + +For a check made at the time of an event, call `router.matchRoute` directly: + +```tsx +import { useRouter } from '@tanstack/react-router' + +function Component() { + const router = useRouter() + + return ( + + ) +} +``` + +Additional matching examples: + +```tsx +import { useMatchRoute } from '@tanstack/react-router' // Current location: /posts/123 function Component() { diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md index b2bb03ad0e..f89c111499 100644 --- a/docs/router/guide/navigation.md +++ b/docs/router/guide/navigation.md @@ -792,7 +792,7 @@ The `router.navigate` method is the same as the `navigate` function returned by ## `useMatchRoute` and `` -The `useMatchRoute` hook and `` component are the same thing, but the hook is a bit more flexible. They both accept the standard navigation `ToOptions` interface either as options or props and return `true/false` if that route is currently matched. It also has a handy `pending` option that will return `true` if the route is currently pending (e.g. a route is currently transitioning to that route). This can be extremely useful for showing optimistic UI around where a user is navigating: +The `useMatchRoute` hook and `` component are the same thing, but the hook is a bit more flexible. They both accept the standard navigation `ToOptions` interface, either as options or props, to determine whether a route is currently matched. The `pending` option checks whether the route is currently pending (e.g. the router is currently transitioning to that route). This can be extremely useful for showing optimistic UI around where a user is navigating: ```tsx function Component() { @@ -828,7 +828,7 @@ function Component() { } ``` -The hook version `useMatchRoute` returns a function that can be called programmatically to check if a route is matched: +The hook version `useMatchRoute` returns a function for checking whether a route is matched. It subscribes the component to the router state used for matching, so use it when the result affects rendering or to trigger an effect. This subscription ensures the component updates as the current or pending location changes: ```tsx function Component() { @@ -838,7 +838,7 @@ function Component() { if (matchRoute({ to: '/users', pending: true })) { console.info('The /users route is matched and pending') } - }) + }, [matchRoute]) return (
@@ -848,6 +848,26 @@ function Component() { } ``` +The `matchRoute` function returned by `useMatchRoute` changes identity when the relevant router state changes. If you only need to check the route at the time an event occurs, use the stable router instance returned by `useRouter` and call `router.matchRoute` directly. This reads the latest router state without subscribing the component to matching state that it does not use while rendering: + +```tsx +function Component() { + const router = useRouter() + + return ( + + ) +} +``` + --- Phew! That's a lot of navigating! That said, hopefully you're feeling pretty good about getting around your application now. Let's move on! diff --git a/e2e/react-router/react-compiler/index.html b/e2e/react-router/react-compiler/index.html new file mode 100644 index 0000000000..0c5b6b471c --- /dev/null +++ b/e2e/react-router/react-compiler/index.html @@ -0,0 +1,12 @@ + + + + + + React Compiler useMatchRoute test + + +
+ + + diff --git a/e2e/react-router/react-compiler/package.json b/e2e/react-router/react-compiler/package.json new file mode 100644 index 0000000000..d4d0e6473a --- /dev/null +++ b/e2e/react-router/react-compiler/package.json @@ -0,0 +1,29 @@ +{ + "name": "tanstack-router-e2e-react-compiler", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@playwright/test": "^1.61.0", + "@rolldown/plugin-babel": "^0.2.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.14" + } +} diff --git a/e2e/react-router/react-compiler/playwright.config.ts b/e2e/react-router/react-compiler/playwright.config.ts new file mode 100644 index 0000000000..61a995a815 --- /dev/null +++ b/e2e/react-router/react-compiler/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { baseURL }, + webServer: { + command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm dev:e2e --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/react-compiler/src/main.tsx b/e2e/react-router/react-compiler/src/main.tsx new file mode 100644 index 0000000000..e6e549da83 --- /dev/null +++ b/e2e/react-router/react-compiler/src/main.tsx @@ -0,0 +1,73 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + linkOptions, + useMatchRoute, +} from '@tanstack/react-router' + +const links = linkOptions([ + { to: '/home', label: 'Home' }, + { to: '/about', label: 'About' }, +]) + +function useRouteName() { + const matchRoute = useMatchRoute() + + return links.find((link) => matchRoute(link))?.label ?? 'Unknown' +} + +function RootComponent() { + const matchedRoute = useRouteName() + + return ( + <> + +

+ Matched route: {matchedRoute} +

+ + + ) +} + +const rootRoute = createRootRoute({ component: RootComponent }) +const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/home', +}) +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', +}) +const router = createRouter({ + routeTree: rootRoute.addChildren([homeRoute, aboutRoute]), +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element not found') +} + +createRoot(rootElement).render( + + + , +) diff --git a/e2e/react-router/react-compiler/tests/use-match-route.spec.ts b/e2e/react-router/react-compiler/tests/use-match-route.spec.ts new file mode 100644 index 0000000000..b800e3c908 --- /dev/null +++ b/e2e/react-router/react-compiler/tests/use-match-route.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test' + +test('useMatchRoute updates after navigation with React Compiler', async ({ + page, +}) => { + await page.goto('/home') + await expect(page.getByTestId('matched-route')).toHaveText('Home') + + await page.getByRole('link', { name: 'About' }).click() + await expect(page).toHaveURL(/\/about$/) + await expect(page.getByTestId('matched-route')).toHaveText('About') + + await page.getByRole('link', { name: 'Home' }).click() + await expect(page).toHaveURL(/\/home$/) + await expect(page.getByTestId('matched-route')).toHaveText('Home') +}) diff --git a/e2e/react-router/react-compiler/tsconfig.json b/e2e/react-router/react-compiler/tsconfig.json new file mode 100644 index 0000000000..4f6089bc08 --- /dev/null +++ b/e2e/react-router/react-compiler/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/react-compiler/vite.config.js b/e2e/react-router/react-compiler/vite.config.js new file mode 100644 index 0000000000..b4478aced4 --- /dev/null +++ b/e2e/react-router/react-compiler/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react, { reactCompilerPreset } from '@vitejs/plugin-react' +import babel from '@rolldown/plugin-babel' + +export default defineConfig({ + plugins: [react(), babel({ presets: [reactCompilerPreset()] })], +}) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 3820f99f89..cada2373ff 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -146,34 +146,40 @@ export type UseMatchRouteOptions< * `search`, etc.) and returns either `false` (no match) or the matched params * object when the route matches the current or pending location. * - * Useful for conditional rendering and active UI states. + * Useful for conditional rendering and active UI states because it subscribes + * the component to the router state used for matching. The returned function's + * identity changes when that state changes. For imperative checks in event + * handlers, get the router with `useRouter` and call `router.matchRoute(...)` + * to avoid that subscription. * * @returns A `matchRoute(options)` function that returns `false` or params. * @link https://tanstack.com/router/latest/docs/framework/react/api/router/useMatchRouteHook */ -export function useMatchRoute() { +export function useMatchRoute(): < + const TFrom extends string = string, + const TTo extends string | undefined = undefined, + const TMaskFrom extends string = TFrom, + const TMaskTo extends string = '', +>( + opts: UseMatchRouteOptions, +) => false | Expand['types']['allParams']> { const router = useRouter() + if (isServer ?? router.isServer) { + return (opts) => { + const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts - if (!(isServer ?? router.isServer)) { - // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.location, (location) => location.href) - // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.resolvedLocation, (location) => location?.href) - // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.status, (status) => status) + return router.matchRoute(rest as any, { + pending, + caseSensitive, + fuzzy, + includeSearch, + }) + } } + // eslint-disable-next-line react-hooks/rules-of-hooks return React.useCallback( - < - const TFrom extends string = string, - const TTo extends string | undefined = undefined, - const TMaskFrom extends string = TFrom, - const TMaskTo extends string = '', - >( - opts: UseMatchRouteOptions, - ): - | false - | Expand['types']['allParams']> => { + (opts) => { const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts return router.matchRoute(rest as any, { @@ -183,7 +189,15 @@ export function useMatchRoute() { includeSearch, }) }, - [router], + [ + router, + // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps + useStore(router.stores.location, (location) => location.href), + // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps + useStore(router.stores.resolvedLocation, (location) => location?.href), + // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps + useStore(router.stores.status, (status) => status), + ], ) } diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index 33153a4e11..93dca8b9c0 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, + renderHook, screen, waitFor, } from '@testing-library/react' @@ -12,6 +13,7 @@ import { createControlledPromise } from '@tanstack/router-core' import { Link, Outlet, + RouterContextProvider, RouterProvider, createRootRoute, createRoute, @@ -232,6 +234,59 @@ test('useMatchRoute follows superseding pending locations', async () => { }) }) +test('useMatchRoute callback identity tracks route matching state', async () => { + const root = createRootRoute() + const a = createRoute({ + getParentRoute: () => root, + path: '/a', + }) + const b = createRoute({ + getParentRoute: () => root, + path: '/b', + }) + const router = createRouter({ + routeTree: root.addChildren([a, b]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const aLocation = router.buildLocation({ to: '/a' }) + const bLocation = router.buildLocation({ to: '/b' }) + + const { result, rerender } = renderHook(() => useMatchRoute(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + let previous = result.current + rerender() + expect(result.current).toBe(previous) + + async function expectCallbackInvalidated(update: () => void) { + previous = result.current + act(update) + await waitFor(() => expect(result.current).not.toBe(previous)) + } + + await expectCallbackInvalidated(() => { + router.stores.location.set(aLocation) + }) + await expectCallbackInvalidated(() => { + router.stores.resolvedLocation.set(bLocation) + }) + await expectCallbackInvalidated(() => { + router.stores.status.set('pending') + }) + + previous = result.current + act(() => { + router.stores.location.set({ ...aLocation }) + router.stores.resolvedLocation.set({ ...bLocation }) + router.stores.status.set('pending') + }) + rerender() + expect(result.current).toBe(previous) +}) + test('legacy notFoundRoute drops a stale parent layout after navigation', async () => { let legacyLoads = 0 const root = createRootRoute({ component: Outlet }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e346692d27..5f34692c41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,46 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-router/react-compiler: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@babel/core': + specifier: ^7.29.0 + version: 7.29.0 + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@rolldown/plugin-babel': + specifier: ^0.2.0 + version: 0.2.3(@babel/core@7.29.0)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-router/rspack-basic-file-based: dependencies: '@tanstack/react-router': @@ -18980,6 +19020,23 @@ packages: '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -21598,6 +21655,9 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + babel-plugin-vue-jsx-hmr@1.0.0: resolution: {integrity: sha512-XRq+XTD4bub6HkavELMhihvLX2++JkSBAxRXlqQK32b+Tb0S9PEqxrDSMpOEZ1iGyOaJZj9Y0uU/FzICdyL9MA==} @@ -32550,6 +32610,14 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.2': {} + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.0 + picomatch: 4.0.4 + rolldown: 1.0.2 + optionalDependencies: + vite: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + '@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.9': {} @@ -34810,6 +34878,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + babel-plugin-react-compiler: 1.0.0 + '@vitejs/plugin-react@6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -35661,6 +35737,10 @@ snapshots: cosmiconfig: 7.1.0 resolve: 1.22.11 + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + babel-plugin-vue-jsx-hmr@1.0.0: dependencies: '@babel/core': 7.28.5