Skip to content

fix(react-router): prevent stale closures in useMatchRoute with React Compiler#6561

Open
kamalbennani wants to merge 4 commits intoTanStack:mainfrom
kamalbennani:fix/react-compiler-use-match-route-stale-closure
Open

fix(react-router): prevent stale closures in useMatchRoute with React Compiler#6561
kamalbennani wants to merge 4 commits intoTanStack:mainfrom
kamalbennani:fix/react-compiler-use-match-route-stale-closure

Conversation

@kamalbennani
Copy link

@kamalbennani kamalbennani commented Jan 31, 2026

Problem

React Compiler's aggressive memoization causes useMatchRoute to return stale results after navigation (issue #4499). The hook uses useCallback with only the stable router reference as a dependency, but the callback reads mutable internal router state (location, resolvedLocation, status).

When React Compiler optimizes the code, it memoizes the callback with captured stale state, preventing it from seeing navigation changes.

⚠️ Important: When the Bug Manifests

The bug specifically manifests when matchRoute results are consumed in useEffect dependencies:

function Component() {
  const matchRoute = useMatchRoute()
  const isActive = !!matchRoute({ to: '/home' })

  useEffect(() => {
    // Do something when route becomes active
    if (isActive) doSomething()
  }, [isActive])  // ← React Compiler creates stale closure here
}

Without the useEffect pattern, React Compiler doesn't trigger the problematic memoization, which is why some tests may pass without the fix. However, consuming matchRoute results in useEffect is a common real-world pattern, making this fix essential.

Solution

This PR fixes the issue by:

  1. Capturing router state explicitly - Store the result of useRouterState in a variable
  2. Adding state to dependencies - Include routerState in the useCallback dependency array
  3. Ensuring callback recreation - The callback now recreates when navigation occurs, providing fresh values to components using the useEffect pattern

Test Added

Added a comprehensive e2e test project (e2e/react-router/react-compiler/) that:

  • Enables React Compiler via babel-plugin-react-compiler
  • Demonstrates the useEffect pattern (lines 30-33 in main.tsx)
  • Tests navigation between routes
  • Verifies match status updates correctly

The e2e test includes console logging to capture the behavior when the bug would manifest.

Related Issue

Fixes #4499

Breaking Changes

None - This is a bug fix that maintains the same public API.


Co-Authored-By: Claude Sonnet 4.5 noreply@anthropic.com

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a stale-closure issue in route state handling to ensure consistent route matching during navigation.
  • New Features

    • Added a minimal demo app to exercise route matching and link active-state behavior.
  • Tests

    • Added end-to-end tests and test setup to validate navigation, match state updates, and active-link highlighting with the React Compiler.
  • Chores

    • Added project config files and ignore patterns to support the new e2e test environment.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 31, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a routerState subscription to the useMatchRoute hook to prevent stale closures, and introduces an end-to-end test suite (React Compiler enabled) plus supporting infra to validate the fix.

Changes

Cohort / File(s) Summary
Core Hook Fix
packages/react-router/src/Matches.tsx
Subscribe to routerState via useRouterState (selector on [location.href, resolvedLocation?.href, status]) and add routerState to useCallback deps to prevent stale closures.
E2E Test Scaffolding
e2e/react-router/react-compiler/package.json, e2e/react-router/react-compiler/tsconfig.json, e2e/react-router/react-compiler/vite.config.js, e2e/react-router/react-compiler/.gitignore
Add project manifest, TS config, Vite config (React + babel-plugin-react-compiler), and gitignore for the test package.
Playwright Config & Lifecycle
e2e/react-router/react-compiler/playwright.config.ts, e2e/react-router/react-compiler/tests/setup/global.setup.ts, e2e/react-router/react-compiler/tests/setup/global.teardown.ts
Add Playwright config with dynamic ports and webServer lifecycle; add global setup/teardown that starts/stops dummy E2E server.
E2E App & Tests
e2e/react-router/react-compiler/src/main.tsx, e2e/react-router/react-compiler/index.html, e2e/react-router/react-compiler/tests/use-match-route.spec.ts
Add a minimal test app demonstrating useMatchRoute with React Compiler and Playwright tests asserting match updates and active link highlighting on navigation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel

Poem

🐰 I hopped through hooks to chase a stale trace,
Subscribed routerState to keep matches in place.
Tests now run swift, links glow when they should,
The routes dance again — all is well in the wood. 🥕

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: fixing stale closures in useMatchRoute when using React Compiler, which is the core objective of this PR.
Linked Issues check ✅ Passed The PR fully addresses issue #4499 by fixing the dependency management in useMatchRoute, subscribing to router state, and validating with e2e tests with React Compiler enabled.
Out of Scope Changes check ✅ Passed All changes are in scope: core fix to useMatchRoute in Matches.tsx, comprehensive e2e test infrastructure, configuration files, and test files for React Compiler scenario.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/react-router/tests/Matches.test.tsx`:
- Around line 358-439: TestComponent is only mounted on indexRoute (path '/'),
but the test starts at '/home' so TestComponent is never rendered; move
TestComponent to the root layout so it remains mounted across navigations.
Update the route tree by attaching TestComponent as the root route's component
(or create a root layout route whose component is TestComponent) instead of
assigning it to indexRoute; adjust references to indexRoute/rootRoute used in
createRoute and createRouter so matches include TestComponent for routes like
'/home' and '/about'.

@schiller-manuel
Copy link
Contributor

we should add a new, small e2e test project that has react-compiler enabled and that demonstrates this issue. please do this as a separate commit

@kamalbennani
Copy link
Author

Hey @schiller-manuel, thanks for the suggestion, I've added a comprehensive e2e test project in a separate commit:

E2E Test Project: e2e/react-router/react-compiler/

  • Enables React Compiler via babel-plugin-react-compiler: ^1.0.0
  • Demonstrates the exact pattern that triggers the bug (see src/main.tsx lines 30-33)
  • Tests navigation between routes and verifies match status updates correctly

Key Discovery: The bug specifically manifests when matchRoute results are consumed in useEffect dependencies:

  const isHome = !!matchRoute({ to: '/home' })
  useEffect(() => {
    console.log('isHome', isHome)
  }, [isHome])  // ← React Compiler's aggressive memoization creates stale closure here

Without this useEffect pattern, React Compiler doesn't trigger the problematic memoization. This is why the test checks DOM state directly rather than through useEffect - but the demo app (main.tsx) includes the useEffect pattern to demonstrate the real-world scenario.

The e2e tests pass with the fix, confirming the solution works correctly with React Compiler enabled.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@e2e/react-router/react-compiler/package.json`:
- Around line 13-25: The package.json uses workspace:^ for internal packages
which violates the guideline; update the dependency entries for
"@tanstack/react-router" and the devDependency "@tanstack/router-e2e-utils" to
use "workspace:*" instead of "workspace:^" so they are consistently pinned
across the workspace (modify the dependencies/devDependencies blocks in
package.json where those package names appear).
🧹 Nitpick comments (2)
e2e/react-router/react-compiler/src/main.tsx (2)

30-33: Include isAbout in the effect deps (or document the intentional omission).

The effect reads isAbout but doesn’t depend on it, which can trigger exhaustive‑deps lint or lead to stale logs. If the omission is intentional for the repro, add an inline eslint disable with a brief rationale; otherwise, include isAbout.

Suggested fix
-  }, [isHome])
+  }, [isHome, isAbout])

127-132: Avoid non‑null assertion to keep strict type safety.

document.getElementById('app')! bypasses strict checks; a guard keeps failures explicit and preserves type safety.

Suggested fix
-const rootElement = document.getElementById('app')!
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+  throw new Error('Expected `#app` element to exist')
+}

As per coding guidelines: **/*.{ts,tsx}: Use TypeScript strict mode with extensive type safety for all code.

@kamalbennani kamalbennani force-pushed the fix/react-compiler-use-match-route-stale-closure branch from addd879 to 75a12b0 Compare February 3, 2026 13:12
@schiller-manuel
Copy link
Contributor

is this a react compiler bug that we are working around here?

@kamalbennani
Copy link
Author

No, this is not a React Compiler bug - this is us fixing improper hook dependency management in our code.

The Problem:

Our useMatchRoute hook was using useCallback with only the router reference as a dependency:

  return React.useCallback((opts) => {
    return router.matchRoute(opts) // reads router.latestLocation, router.state
  }, [router]) // ❌ router reference is stable, but its internal state is mutable

The router reference never changes, but the callback reads mutable internal state (router.latestLocation, router.state.resolvedLocation, etc.). This violates React's rules - we're depending on values that aren't in the dependency array.

Why It Worked Before:

When navigation occurs, useRouterState triggers a re-render of the component. If the callback is called during render, it works fine because it reads the router's current internal state:

  // This works - callback called during render
  return <div>{matchRoute({ to: '/home' }) ? 'Active' : 'Inactive'}</div>

However, if the callback result is used in a useEffect, that effect won't re-run after navigation because the callback reference hasn't changed, even though the component re-rendered:

  // This breaks - useEffect doesn't see the callback reference change
  useEffect(() => {
    const isHome = matchRoute({ to: '/home' })
    if (isHome) {
       doSomething()
    }
  }, [matchRoute]) // ❌ Effect won't re-run after navigation

Why React Compiler Exposes It:

React Compiler enforces proper memoization semantics. When it sees useCallback([router]) with a stable reference, it assumes the callback never needs to update. This exposes our improper dependency management.

The Fix:

We now explicitly subscribe to the router state and include it in dependencies:

  const routerState = useRouterState({
    select: (s) => [s.location.href, s.resolvedLocation?.href, s.status]
  })

  return React.useCallback((opts) => {
    return router.matchRoute(opts)
  }, [router, routerState]) // ✅ Explicit dependencies on values that actually change

This is the correct pattern. As the React team moves toward requiring proper memoization (removing the burden from developers to manually optimize), our libraries need to follow best practices. This fix ensures our code works correctly with React Compiler and future React versions.

@kamalbennani
Copy link
Author

Hey @schiller-manuel, if you have any further comments, I'd love to address them.

@kamalbennani kamalbennani force-pushed the fix/react-compiler-use-match-route-stale-closure branch from 75a12b0 to a724bfe Compare February 6, 2026 19:22
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@e2e/react-router/react-compiler/package.json`:
- Around line 24-25: Update the dependency version for
babel-plugin-react-compiler in package.json from "^1.0.0" to the current release
(e.g., "19.1.0-rc.3"); locate the dependency entry for
"babel-plugin-react-compiler" and replace its version string so the project uses
the up-to-date package while leaving "vite" unchanged.

@JeremyMoeglich
Copy link

This would be great to have, it's the only place I have react compiler disabled via "use no memo" right now.

kamalbennani-aircall and others added 4 commits February 16, 2026 23:33
… Compiler

React Compiler's aggressive memoization can cause useMatchRoute to return
stale results after navigation. The hook's useCallback only depended on the
stable router reference, but the callback reads mutable router internal state
(location, resolvedLocation, status).

This fix:
- Captures router state explicitly in a variable
- Includes routerState in useCallback dependencies
- Ensures the callback is recreated when navigation occurs

Adds test demonstrating the issue:
- Navigate from /home to /about
- Call matchRoute to check current location
- Verify it correctly identifies the new route

Without this fix, React Compiler would memoize the callback with stale
router state, causing incorrect match results.

Fixes: TanStack#4499

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…er test

Mount TestComponent on root route instead of index route to ensure it
remains mounted during navigation. The test starts at /home but was
mounting TestComponent on the index route (/), causing it to never render.

Now TestComponent is properly mounted as the root component with an Outlet,
so it stays mounted when navigating between /home and /about routes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Creates a new e2e test project that demonstrates and verifies the
useMatchRoute fix works correctly with React Compiler enabled.

The test project:
- Enables React Compiler via babel-plugin-react-compiler in vite config
- Creates a minimal app using useMatchRoute to check current route
- Tests navigation between /home and /about routes
- Verifies matchRoute returns correct results after navigation
- Checks active link highlighting based on match status

Without the fix (with React Compiler enabled but without routerState
in dependencies), the tests would fail because matchRoute would return
stale results captured at hook initialization.

With the fix, all tests pass because the callback properly updates when
navigation occurs.

This addresses the maintainer request for an e2e test that demonstrates
the issue with React Compiler enabled.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The test is now covered by the e2e test in the react-compiler example app.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@kamalbennani kamalbennani force-pushed the fix/react-compiler-use-match-route-stale-closure branch from a724bfe to 00830c9 Compare February 16, 2026 22:33
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@e2e/react-router/react-compiler/src/main.tsx`:
- Around line 92-112: The inline anonymous components passed as the component
prop to createRoute for homeRoute and aboutRoute should be extracted to named
React components to avoid creating new component references on every render;
define e.g. HomePage and AboutPage functions and replace the component: () =>
(...) entries with component: HomePage and component: AboutPage respectively,
keeping the same JSX content and data-testid attributes so behavior and tests
remain unchanged.
🧹 Nitpick comments (1)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@e2e/react-router/react-compiler/src/main.tsx`:
- Around line 92-112: The inline anonymous components passed as the component
prop to createRoute for homeRoute and aboutRoute should be extracted to named
React components to avoid creating new component references on every render;
define e.g. HomePage and AboutPage functions and replace the component: () =>
(...) entries with component: HomePage and component: AboutPage respectively,
keeping the same JSX content and data-testid attributes so behavior and tests
remain unchanged.
e2e/react-router/react-compiler/src/main.tsx (1)

92-112: Consider extracting inline route components to named functions.

The anonymous arrow functions passed as component to createRoute will create new component references on every render cycle, which can cause unnecessary unmount/remount. While this is fine for an e2e test, using named components is more idiomatic with TanStack Router and avoids potential issues if React Compiler applies additional optimizations to these components.

♻️ Optional: extract to named components
+function HomeComponent() {
+  return (
+    <div data-testid="content-home">
+      <h3>Home Page</h3>
+      <p>Welcome to the home page!</p>
+    </div>
+  )
+}
+
+function AboutComponent() {
+  return (
+    <div data-testid="content-about">
+      <h3>About Page</h3>
+      <p>This is the about page.</p>
+    </div>
+  )
+}
+
 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>
-  ),
+  component: HomeComponent,
 })
 
 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>
-  ),
+  component: AboutComponent,
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/react-router/react-compiler/src/main.tsx` around lines 92 - 112, The
inline anonymous components passed as the component prop to createRoute for
homeRoute and aboutRoute should be extracted to named React components to avoid
creating new component references on every render; define e.g. HomePage and
AboutPage functions and replace the component: () => (...) entries with
component: HomePage and component: AboutPage respectively, keeping the same JSX
content and data-testid attributes so behavior and tests remain unchanged.

@kamalbennani
Copy link
Author

Hey @schiller-manuel @tannerlinsley, I know you guys have a lot to deal with, but I was wondering if there is anything I could do to get this PR approved?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useMatchRoute not updating when using React Compiler

4 participants