Skip to content

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

Open
kamalbennani wants to merge 5 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 5 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

    • Prevented stale match state so navigation now reliably updates which routes are active.
  • Tests

    • Added end-to-end tests verifying route match updates and active-link highlighting across navigations.
  • Chores

    • Added end-to-end testing infrastructure, build/dev config, and test setup/teardown support.

… 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>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 31, 2026

📝 Walkthrough

Walkthrough

Subscribes useMatchRoute to router state by adding a useRouterState selector and including routerState in useCallback deps; removes an existing unit test file and adds a React-Compiler e2e project with Playwright tests and supporting config/files to validate the behavior across navigations.

Changes

Cohort / File(s) Summary
Hook Implementation
packages/react-router/src/Matches.tsx
Adds a useRouterState selector subscription and includes routerState in useCallback deps to avoid stale closures with React Compiler; adds explanatory comments.
Unit Tests Removed
packages/react-router/tests/Matches.test.tsx
Deletes the existing Matches.test.tsx file (removes extensive route fixtures and unit tests for useMatchRoute).
E2E React-Compiler Project Files
e2e/react-router/react-compiler/package.json, e2e/react-router/react-compiler/vite.config.js, e2e/react-router/react-compiler/playwright.config.ts, e2e/react-router/react-compiler/tsconfig.json, e2e/react-router/react-compiler/.gitignore, e2e/react-router/react-compiler/index.html
Adds a standalone e2e project configured for React Compiler: Vite + babel-plugin-react-compiler, Playwright config, TS config, manifest, and static HTML entry.
E2E App & Tests
e2e/react-router/react-compiler/src/main.tsx, e2e/react-router/react-compiler/tests/use-match-route.spec.ts, e2e/react-router/react-compiler/tests/setup/global.setup.ts, e2e/react-router/react-compiler/tests/setup/global.teardown.ts
Adds a minimal router app demonstrating the stale-closure issue, Playwright e2e tests verifying useMatchRoute updates and link highlighting, and global setup/teardown for a dummy test server.
Manifest
package.json
Minor manifest edits (lines changed: +10/-2).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel

Poem

🐰
I hopped through hooks at break of dawn,
Pulled stale closures gently on,
A router whisper, callback new,
Now matches wake with every view —
🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main change: fixing stale closures in useMatchRoute when React Compiler is enabled.
Linked Issues check ✅ Passed The PR fully addresses issue #4499 by capturing router state in useMatchRoute and adding it as a dependency, ensuring matchRoute reflects navigation changes immediately when React Compiler is enabled.
Out of Scope Changes check ✅ Passed Changes are within scope: core fix to Matches.tsx addressing stale closures, removal of replaced unit tests, and comprehensive e2e test project demonstrating and validating the fix with React Compiler enabled.

✏️ 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

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-aircall and others added 2 commits February 3, 2026 10:01
…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>
@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 and others added 2 commits February 3, 2026 13:57
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 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.

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

3 participants