Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions e2e/react-router/react-compiler/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist
node_modules
.DS_Store
*.txt
12 changes: 12 additions & 0 deletions e2e/react-router/react-compiler/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Compiler - useMatchRoute Test</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
27 changes: 27 additions & 0 deletions e2e/react-router/react-compiler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"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": {
"@playwright/test": "^1.50.1",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^7.3.1"
}
}
42 changes: 42 additions & 0 deletions e2e/react-router/react-compiler/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test'
import {
getDummyServerPort,
getTestServerPort,
} from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const PORT = await getTestServerPort(packageJson.name)
const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
workers: 1,

reporter: [['line']],

globalSetup: './tests/setup/global.setup.ts',
globalTeardown: './tests/setup/global.teardown.ts',

use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL,
},

webServer: {
command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && pnpm preview --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
132 changes: 132 additions & 0 deletions e2e/react-router/react-compiler/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import ReactDOM from 'react-dom/client'
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
useMatchRoute,
} from '@tanstack/react-router'
import { useEffect } from 'react'

/**
* This e2e test demonstrates the React Compiler stale closure issue with useMatchRoute.
*
* The issue: When matchRoute results are consumed in useEffect dependencies, React
* Compiler's aggressive memoization causes the callback to capture stale router state.
* The useEffect pattern (lines 30-33) is critical - without it, the bug doesn't manifest.
*
* With the fix: routerState is included in useCallback dependencies, ensuring the
* callback is recreated when navigation occurs, so useEffect gets fresh values.
*/

function RootComponent() {
const matchRoute = useMatchRoute()

const isHome = !!matchRoute({ to: '/home' })
const isAbout = !!matchRoute({ to: '/about' })

useEffect(() => {
console.log('isHome', isHome);
console.log('isAbout', isAbout);
}, [isHome])


return (
<div style={{ padding: '20px' }}>
<h1>React Compiler useMatchRoute Test</h1>

<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
<Link
to="/home"
style={{
padding: '8px 16px',
background: isHome ? '#4CAF50' : '#ddd',
color: isHome ? 'white' : 'black',
textDecoration: 'none',
borderRadius: '4px',
}}
data-testid="link-home"
>
Home
</Link>
<Link
to="/about"
style={{
padding: '8px 16px',
background: isAbout ? '#4CAF50' : '#ddd',
color: isAbout ? 'white' : 'black',
textDecoration: 'none',
borderRadius: '4px',
}}
data-testid="link-about"
>
About
</Link>
</div>

<div style={{ marginBottom: '20px' }}>
<h2>Match Status:</h2>
<div>
<strong>Is Home:</strong>{' '}
<span data-testid="match-home">{isHome ? 'true' : 'false'}</span>
</div>
<div>
<strong>Is About:</strong>{' '}
<span data-testid="match-about">{isAbout ? 'true' : 'false'}</span>
</div>
</div>

<hr />

<Outlet />
</div>
)
}

const rootRoute = createRootRoute({
component: RootComponent,
})

const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/home',
component: () => (
<div data-testid="content-home">
<h3>Home Page</h3>
<p>Welcome to the home page!</p>
</div>
),
})

const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: () => (
<div data-testid="content-about">
<h3>About Page</h3>
<p>This is the about page.</p>
</div>
),
})

const routeTree = rootRoute.addChildren([homeRoute, aboutRoute])

const router = createRouter({
routeTree,
defaultPreload: 'intent',
})

declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

const rootElement = document.getElementById('app')!

if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(<RouterProvider router={router} />)
}
6 changes: 6 additions & 0 deletions e2e/react-router/react-compiler/tests/setup/global.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { e2eStartDummyServer } from '@tanstack/router-e2e-utils'
import packageJson from '../../package.json' with { type: 'json' }

export default async function setup() {
await e2eStartDummyServer(packageJson.name)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { e2eStopDummyServer } from '@tanstack/router-e2e-utils'
import packageJson from '../../package.json' with { type: 'json' }

export default async function teardown() {
await e2eStopDummyServer(packageJson.name)
}
75 changes: 75 additions & 0 deletions e2e/react-router/react-compiler/tests/use-match-route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { expect, test } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from '../package.json' with { type: 'json' }

const PORT = await getTestServerPort(packageJson.name)

/**
* This e2e test verifies that useMatchRoute works correctly with React Compiler enabled.
*
* Without the fix, React Compiler would memoize the matchRoute callback with stale
* router state, causing it to return incorrect match results after navigation.
*
* With the fix, the callback properly updates when navigation occurs, so the match
* status is always accurate.
*/

test.beforeEach(async ({ page }) => {
await page.goto('/home')
})

test('useMatchRoute should update after navigation with React Compiler', async ({
page,
}) => {
// Initially at /home
await expect(page.getByTestId('match-home')).toHaveText('true')
await expect(page.getByTestId('match-about')).toHaveText('false')
await expect(page.getByTestId('content-home')).toBeVisible()

// Navigate to /about
await page.getByTestId('link-about').click()

// After navigation, matchRoute should correctly identify we're at /about
// Without the fix, this would fail because matchRoute would still think we're at /home
await expect(page.getByTestId('match-home')).toHaveText('false')
await expect(page.getByTestId('match-about')).toHaveText('true')
await expect(page.getByTestId('content-about')).toBeVisible()

// Navigate back to /home to verify it works both ways
await page.getByTestId('link-home').click()

await expect(page.getByTestId('match-home')).toHaveText('true')
await expect(page.getByTestId('match-about')).toHaveText('false')
await expect(page.getByTestId('content-home')).toBeVisible()
})

test('useMatchRoute should correctly highlight active navigation', async ({
page,
}) => {
// Home link should be highlighted (green background)
await expect(page.getByTestId('link-home')).toHaveCSS(
'background-color',
'rgb(76, 175, 80)',
)

// About link should not be highlighted (gray background)
await expect(page.getByTestId('link-about')).toHaveCSS(
'background-color',
'rgb(221, 221, 221)',
)

// Navigate to about
await page.getByTestId('link-about').click()

// Now About should be highlighted
await expect(page.getByTestId('link-about')).toHaveCSS(
'background-color',
'rgb(76, 175, 80)',
)

// And Home should not be highlighted
await expect(page.getByTestId('link-home')).toHaveCSS(
'background-color',
'rgb(221, 221, 221)',
)
})
15 changes: 15 additions & 0 deletions e2e/react-router/react-compiler/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
13 changes: 13 additions & 0 deletions e2e/react-router/react-compiler/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
})
12 changes: 10 additions & 2 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ export type UseMatchRouteOptions<
export function useMatchRoute<TRouter extends AnyRouter = RegisteredRouter>() {
const router = useRouter()

useRouterState({
// Subscribe to router state and capture the values
// This ensures the callback below has explicit dependencies on state that changes
const routerState = useRouterState({
select: (s) => [s.location.href, s.resolvedLocation?.href, s.status],
structuralSharing: true as any,
})
Expand All @@ -165,7 +167,13 @@ export function useMatchRoute<TRouter extends AnyRouter = RegisteredRouter>() {
includeSearch,
})
},
[router],
// Include routerState in dependencies to prevent React Compiler from
// memoizing with stale router state. The router reference is stable,
// but router.matchRoute() reads mutable internal state (location, status).
// By including routerState, we ensure the callback is recreated when
// navigation occurs, preventing stale closure issues.
// eslint-disable-next-line react-hooks/exhaustive-deps
[router, routerState],
)
}

Expand Down
Loading