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..e014586088 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -154,15 +154,6 @@ export type UseMatchRouteOptions<
export function useMatchRoute() {
const router = useRouter()
- 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 React.useCallback(
<
const TFrom extends string = string,
@@ -183,7 +174,21 @@ export function useMatchRoute() {
includeSearch,
})
},
- [router],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ (isServer ?? router.isServer)
+ ? [router]
+ : [
+ router,
+ // 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),
+ ],
)
}
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