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