镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix(router-core, react-router): keep a departed match readable until its tree unmounts - #8576

Open
Tarekkharsa wants to merge 3 commits into
TanStack:mainfrom
Tarekkharsa:fix/departed-match-during-dehydrated-hydration
Open

Tarekkharsa wants to merge 3 commits into
TanStack:mainfrom
Tarekkharsa:fix/departed-match-during-dehydrated-hydration

Conversation

@Tarekkharsa

@Tarekkharsa Tarekkharsa commented Oct 1, 2026 •

Copy link
Copy Markdown

🎯 Changes

Fixes #8198. Related: #8306.

Supersedes #8485 (moved from my work account).

Reproducer: https://github.com/Tarekkharsa/tanstack-router-departed-match-repro. On the current release, pnpm i && pnpm test exits 1.

Problem

In an SSR app, the user can click a <Link> in the header while the route content's Suspense boundary is still dehydrated. This happens often on React 19, because React hydrates that boundary later than the header. React discards the hydration and logs #422/#520, and in trees with nested outlets the recovery render throws again (MatchView routeId TypeError / useMatch invariant):

TypeError: Cannot read properties of undefined (reading 'routeId')   // MatchView
Invariant failed: Could not find a nearest match!                    // useMatch

Root cause

setMatches clears the store of each route that the user leaves, in the same batch that commits the navigation. Before React unmounts the old tree, it hydrates the dehydrated boundary one time with the old children. In that render:

  • MatchImpl gives undefined to MatchView.
  • Outlet reads _notFound from the cleared parent store. It also uses ids[ids.indexOf(routeId) + 1], and indexOf returns -1, so it renders the root route inside itself.
  • useMatch finds no match and throws.

Fix

  • router-core/stores.ts: setMatches saves the previous ids in stores.previousIds. It also keeps the last match of each route that left in stores.departed (the same objects, no copies). Both are written in the batch that clears the departed stores and cleared at the next change of ids, so they hold at most one entry per route.
  • react-router/Match.tsx: MatchImpl and the Outlet parent selector use stores.departed as a fallback. If no match exists, MatchImpl renders null. Outlet finds the child id in previousIds when its route has left, so it never uses index -1.
  • react-router/useMatch.tsx: uses stores.departed only when the nearest route in context has itself left, that is, only inside the old tree. From a live tree, useMatch({ from }) for an inactive route still throws, as before. useMatch now always reads matchContext, so the PR removes the unused dummyMatchContext.

Only the old tree reads the departed state, and only for the one render before it unmounts. The core change only adds state, so Solid and Vue do not change.

Why not just return null in MatchImpl? That stops the throw, but then the client renders nothing where the server HTML has content. React logs a recoverable hydration error and client-renders the boundary. The departed map keeps the one hydration render equal to the server HTML, so onRecoverableError stays empty.

If the user navigates twice before the old tree hydrates, React tries one hydration per lane and then client-renders the new children; the null path in MatchImpl is only a guard for that case.

Tests

packages/react-router/tests/departed-match-dehydrated-boundary.test.tsx uses only public APIs:

  1. It server-renders /a/child, hydrates it with a child that stays suspended, and clicks a root link to /b. Before the click it asserts that /a has not committed on the client, so the boundary is provably still dehydrated and the result does not depend on React scheduling. It then checks that /b renders one time and that no error callback fires. On main, this test fails with the routeId TypeError.
  2. It checks that useMatch({ from: '/a' }) inside /b still throws.

If you revert any one of the reader changes, a test fails.

Local runs on main at d35aab4 (after #8579), all exit 0: test:unit, test:eslint, test:types, build, test:build for router-core and react-router, and test:unit for solid-router and vue-router. With only the src changes reverted, the new test fails with the routeId TypeError. Bundle size of react-router.minimal: +103 bytes gzip (85965 to 86068).

Follow-up: in the departed render, useParentMatches / useChildMatches return a wrong slice (no throw). Not changed here.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes
    • Fixed navigation during hydration when a previously displayed route is still mounted. The new page can render without errors while the old route finishes hydrating.
    • Preserved access to the departing route’s match within its still-rendering route tree during hydration.
    • useMatch({ from }) continues to report an error when the requested route is no longer active, rather than returning a match from a different page.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: TanStack/router/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: c304facb-814c-48dc-85c4-ee3af8b35109

📥 Commits

Reviewing files that changed from the base of the PR and between 5b180dd and 2ee81aa.

📒 Files selected for processing (2)
  • packages/react-router/tests/departed-match-dehydrated-boundary.test.tsx
  • packages/router-core/src/stores.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

Router stores now retain previous route IDs and the last matches for routes removed from the active match set. React bindings use retained matches when rendering an exiting route tree. useMatch({ from }) still rejects inactive routes outside that tree.

Changes

Departed route match handling

Layer / File(s) Summary
Capture matches for removed routes
packages/router-core/src/stores.ts
RouterStores now exposes previous route IDs and departed matches. When route IDs change, setMatches saves matches for removed routes before clearing their stores.
Render exiting route trees from retained matches
packages/react-router/src/Match.tsx, packages/react-router/src/matchContext.tsx, packages/react-router/src/useMatch.tsx, packages/react-router/tests/departed-match-dehydrated-boundary.test.tsx, .changeset/departed-match-dehydrated-boundary.md
Match, Outlet, and useMatch use departed-match data when active matches are absent. Tests cover hydration during navigation and the inactive-route invariant outside the departed route tree. The changeset declares patch releases for both packages.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: sheraff

Merge Risk: ⚪ Minimal · up to 2ee81

The change preserves departed-route rendering without relaxing inactive-route errors elsewhere. No actionable merge-blocking issue was established; merge after normal checks pass.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 2ee81

The change retains prior route data to support a tree that has not finished unmounting. Active-tree lookups remain restricted, and no security bypass was established. Cancellation and overlapping navigation behavior remain partly unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated exposure is prior match state within the same router instance and its client-rendered trees. The inspected paths do not establish an independently attackable cross-tenant, service, or credential boundary.

Trust Boundaries and Controls

  • observed — useMatch first prefers active state and permits departed fallback only when the nearest route in React context is itself departed. An active B tree requesting departed A therefore retains the normal inactive-match error. This is a rendering-scope control, not an authorization mechanism.

Resilience and Maintainability Implications

  • inferred — Pending offers can publish presentation state before commit. Inspected cancellation paths clear pending-session resources but do not themselves demonstrate restoration of committed matches. This publication behavior predates the PR; its interaction with newly retained data remains a recovery-coverage gap, not an established security regression.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR satisfies the coding requirements in #8198. RouterStores records previousIds and retains departed matches. React Match, Outlet, and useMatch use the departed match while the exiting t…
Out of Scope Changes check ✅ Passed The changes remain within #8198. The router-core state retention, React fallback logic, focused regression tests, and release changeset support the hydration crash fix. Preserving strict lookup behavi…
Title check ✅ Passed The title clearly and concisely describes the main change: preserving departed matches until the old tree unmounts in router-core and react-router.
Description check ✅ Passed The description follows the required template. It explains the problem, root cause, fix, tests, and release impact, and all checklist items are completed appropriately.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

Tarekkharsa and others added 3 commits October 1, 2026 16:13
…ates

Server-render a nested route, keep its Suspense boundary dehydrated with a
child that suspends on the client, and click a root Link. On main the
departed route's Match reads a cleared store and throws "Cannot read
properties of undefined (reading 'routeId')".

Also assert that useMatch({ from }) still throws for a route that is no
longer active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…its tree unmounts

setMatches clears a departed route's store in the same batch that commits
the navigation. When the route's Suspense boundary is still dehydrated,
React hydrates it once with the old children before it applies the
update, so MatchImpl, Outlet and useMatch read undefined.

Keep the previous route ids in stores.previousIds and the last match of
each route that left with that change in stores.departed. MatchImpl and
Outlet fall back to them, and Outlet no longer indexes with -1. useMatch
falls back only when its nearest route has departed, so useMatch({ from })
on an inactive route from a live tree still throws. Remove the now unused
dummyMatchContext.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…click

Also document the lifetime of the `departed` and `previousIds` stores.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Tarekkharsa
Tarekkharsa force-pushed the fix/departed-match-during-dehydrated-hydration branch from 5b180dd to 2ee81aa Compare October 1, 2026 12:23
@Tarekkharsa

Copy link
Copy Markdown
Author

@Sheraff could you take a look when you have time? This fixes #8198 (reproducer linked in the description). The workflows need approval to run, since this is my first PR from this account.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Router throws "Could not find an active match from" when an interrupted concurrent pass renders the exiting tree after match pruning

1 participant