From 6924573126c68a038562069a372d124daee727d5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 22:17:21 +0200 Subject: [PATCH 1/6] fix(react-router): useMatchRoute w/ React Compiler --- packages/react-router/src/Matches.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 3820f99f89..9d7708fc32 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,20 @@ export function useMatchRoute() { includeSearch, }) }, - [router], + (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), + ], ) } From a3f68ce2640d89c08d98ca32f84f998ca26ab6a7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 22:52:21 +0200 Subject: [PATCH 2/6] test(react-router): add React Compiler E2E Co-authored-by: Kamal Bennani --- e2e/react-router/react-compiler/index.html | 12 +++ e2e/react-router/react-compiler/package.json | 29 +++++++ .../react-compiler/playwright.config.ts | 25 ++++++ e2e/react-router/react-compiler/src/main.tsx | 78 ++++++++++++++++++ .../tests/use-match-route.spec.ts | 16 ++++ e2e/react-router/react-compiler/tsconfig.json | 15 ++++ .../react-compiler/vite.config.js | 7 ++ pnpm-lock.yaml | 80 +++++++++++++++++++ 8 files changed, 262 insertions(+) create mode 100644 e2e/react-router/react-compiler/index.html create mode 100644 e2e/react-router/react-compiler/package.json create mode 100644 e2e/react-router/react-compiler/playwright.config.ts create mode 100644 e2e/react-router/react-compiler/src/main.tsx create mode 100644 e2e/react-router/react-compiler/tests/use-match-route.spec.ts create mode 100644 e2e/react-router/react-compiler/tsconfig.json create mode 100644 e2e/react-router/react-compiler/vite.config.js 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..81c84bb086 --- /dev/null +++ b/e2e/react-router/react-compiler/src/main.tsx @@ -0,0 +1,78 @@ +import { StrictMode, useMemo } from 'react' +import { createRoot } from 'react-dom/client' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + linkOptions, + useMatchRoute, + useRouterState, +} from '@tanstack/react-router' + +const links = linkOptions([ + { to: '/home', label: 'Home' }, + { to: '/about', label: 'About' }, +]) + +function useRouteName() { + const routerState = useRouterState() + const matchRoute = useMatchRoute() + + return useMemo( + () => links.find((link) => matchRoute(link))?.label ?? 'Unknown', + [routerState, matchRoute], + ) +} + +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/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 From 740dc3b65218bcebe959d8a860393537ffaf0104 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 22:57:37 +0200 Subject: [PATCH 3/6] disable eslint react-hooks/exhaustive-deps for known static condition w/ DCE --- packages/react-router/src/Matches.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 9d7708fc32..e014586088 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -174,6 +174,7 @@ export function useMatchRoute() { includeSearch, }) }, + // eslint-disable-next-line react-hooks/exhaustive-deps (isServer ?? router.isServer) ? [router] : [ From 67802df7b7cbd2b79713b17e6c4ac3504bd48ec0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 23:10:14 +0200 Subject: [PATCH 4/6] test(react-router): cover useMatchRoute callback identity Co-authored-by: Sarah Gerrard <98355961+LadyBluenotes@users.noreply.github.com> --- packages/react-router/tests/Matches.test.tsx | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) 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 }) From 38c601df9338e880081c8586164ca26978443231 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 23:42:51 +0200 Subject: [PATCH 5/6] test(react-router): rely on useMatchRoute subscription --- e2e/react-router/react-compiler/src/main.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/e2e/react-router/react-compiler/src/main.tsx b/e2e/react-router/react-compiler/src/main.tsx index 81c84bb086..877254bca0 100644 --- a/e2e/react-router/react-compiler/src/main.tsx +++ b/e2e/react-router/react-compiler/src/main.tsx @@ -9,7 +9,6 @@ import { createRouter, linkOptions, useMatchRoute, - useRouterState, } from '@tanstack/react-router' const links = linkOptions([ @@ -18,12 +17,11 @@ const links = linkOptions([ ]) function useRouteName() { - const routerState = useRouterState() const matchRoute = useMatchRoute() return useMemo( () => links.find((link) => matchRoute(link))?.label ?? 'Unknown', - [routerState, matchRoute], + [matchRoute], ) } From f1fffd1632e427cec925db756c0bad6a66669ee3 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 23:56:54 +0200 Subject: [PATCH 6/6] test(react-router): exercise compiler-generated memoization --- e2e/react-router/react-compiler/src/main.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/e2e/react-router/react-compiler/src/main.tsx b/e2e/react-router/react-compiler/src/main.tsx index 877254bca0..e6e549da83 100644 --- a/e2e/react-router/react-compiler/src/main.tsx +++ b/e2e/react-router/react-compiler/src/main.tsx @@ -1,4 +1,4 @@ -import { StrictMode, useMemo } from 'react' +import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { Link, @@ -19,10 +19,7 @@ const links = linkOptions([ function useRouteName() { const matchRoute = useMatchRoute() - return useMemo( - () => links.find((link) => matchRoute(link))?.label ?? 'Unknown', - [matchRoute], - ) + return links.find((link) => matchRoute(link))?.label ?? 'Unknown' } function RootComponent() {