TanStack / TanStack/router

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

Open
#8,198 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

information needed
Dominant language
TypeScript
Stars
15.1k
Forks
1.9k
Avg merge
1d 20h
Merged PRs (30d)
143

Description

Which project does this relate to?

Router

Describe the bug

useMatch's strict from-based lookup throws Invariant failed: Could not find an active match from "<routeId>" when an interrupted React concurrent render pass re-renders the exiting route tree after the router has already pruned that location's match. The throw is swallowed by React's concurrent-error path (a synchronous retry succeeds once the new location's matches are in place) and surfaces only as a recoverable console error, but it fires on roughly 27% of navigations during initial page load in our application, and no component-level workaround can avoid it (details below).

Environment
  • @tanstack/react-router 1.170.32 (latest published at the time of this report)
  • @tanstack/react-start 1.168.49
  • @tanstack/router-core 1.171.27
  • React 19 / react-dom 19, streaming SSR (TanStack Start)
  • Chromium (Playwright-driven Chrome)
  • Observed in a production application with a search-param-heavy streaming-SSR page
What happens

Navigating away from the page (clicking a sidebar link to another route) while the page is still hydrating causes the interrupted concurrent render pass to re-render the exiting route tree. By that point the router has pruned the exiting location's match, so the useMatch call resolving Route.useLoaderData() — which resolves internally to a strict from-based useMatch — throws:

Invariant failed: Could not find an active match from "/_portal/requests"

React recovers by synchronously rendering the entire root and logs "There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root" through onRecoverableError. The UI always recovers, but the error appears in roughly 27% of clicks during initial page load (post-upgrade measurement; see Evidence).

Reproduction shape
  1. Streaming-SSR page with validated search params (/requests, search-param-heavy) served by TanStack Start.
  2. Load the page and wait for the app shell to hydrate.
  3. Click a sidebar link to navigate away mid-hydration.
  4. The interrupted concurrent pass re-renders the exiting tree and the invariant throws.

A throwaway Playwright spec reproduced the flake deterministically with this exact flow (4/4 desktop runs errored at the pre-upgrade frequency below). The thrown value was surfaced with temporary diagnostic patches to the installed react-dom/@tanstack/react-router distributions (reverted afterward).

Evidence

Two forms of the same race were captured, before and after upgrading the router:

Post-upgrade (current, @tanstack/react-router@1.170.32, @tanstack/react-start@1.168.49, @tanstack/router-core@1.171.27):

Invariant failed: Could not find an active match from "/_portal/requests"
  • Thrown from useMatch's strict from-based lookup when the interrupted concurrent pass renders the exiting tree after the router pruned the exiting location's match.
  • Frequency: ~27% of clicks during initial page load (down from ~80% pre-upgrade; the upgrade removed the other mechanism, see below, but this sibling invariant remains).

Pre-upgrade (@tanstack/react-router@1.170.16, ~80% of runs):

Invariant failed: Could not find match for matchId "/_portal/requests/requests{"q":"","asset":"all","status":"all","type":"all","sort":"recent","view":"table"}". Please file an issue!
  • Source: dist/esm/Match.jsMatch was keyed by match ID and router.stores.matchStores.get(matchId) threw the invariant when the store was missing. Match IDs embedded the route's validated search params; on navigation the router pruned the old location's matches from matchStores, while React's concurrent pass could still render the exiting tree (the memoized Match with the old matchId) before the router's store subscription settled. The store lookup missed, the invariant threw, React retried synchronously.
  • Frequency: ~80% of runs — not 100%, because it is a timing race between store pruning and the interrupted concurrent pass.

The upgrade to 1.170.32 rewrote Match to be route-keyed (router.stores.getMatchStore(routeId)), so match stores persist across transitions instead of being pruned per navigation — this eliminated the matchId-store form entirely. The residual post-upgrade invariant is a sibling in the same bug class: a strict lookup inside the router's React bindings throws during the interrupted pass, when the exiting tree's match is already gone.

Why component-level remedies fail

Streaming SSR hydrates the app shell and the route's Suspense content in separate passes; a click landing between them interrupts the route subtree's hydration and triggers the same interrupted concurrent pass during initial hydration (not only during navigation). In that pass the pruned match leaves the route component no honest output:

  • useMatch({ from: "/_portal/requests", shouldThrow: false }) + returning null when the match is missing diverges from the server HTML — this produced hydration-mismatch console errors at a higher rate than the original throw in our measurements.
  • Allowing the hook to throw (default shouldThrow: true) logs the recoverable error described above.

We implemented the shouldThrow: false + null-render remedy and measured both outcomes; neither meets a zero-console-error bar. No component-level remedy exists — the router's React bindings throw (or cannot render honestly) during the interrupted pass, so the fix has to be in the router: the exiting tree's match data needs to remain readable (or the interrupted pass needs to be a no-op for pruned matches) for the duration of React's concurrent pass.

React recoverable-error mechanism

react-dom's concurrent-error path wraps the thrown value as Error("There was an error during concurrent rendering…", { cause: value }) and reports it through onRecoverableError when a concurrent render pass throws and a synchronous retry succeeds. The original thrown value (the router invariant) rides in cause; the browser pageerror event serializes the cause as undefined, so standard error consumers see only the wrapper text and cannot tell what was actually thrown without instrumenting react-dom.

Related issues
  • #7910 — stale match snapshot rendered during hydration (ssr: false route): a related mixing of two points in time between the router's match state and React's render, surfacing inside MatchInnerImpl/Match.
  • #7029 — hydrated loaderData overwritten during initial client load, causing a hydration mismatch: the same window around initial hydration where router state and hydration passes interact badly.
  • #1368, #2417, #2501, #5346 — report the same "Could not find an active match from" invariant message, but with different root causes (route-ID/layout/index-route mismatches rather than match pruning racing an interrupted concurrent pass).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the described Playwright flow: load the streaming-SSR page, interrupt hydration by navigating away, and observe the recoverable error. Inspect dist/esm/Match.js and the React bindings around useMatch and MatchInnerImpl/Match, focusing on the pruned exiting match during the interrupted pass. Done means the exiting match remains readable or the pass becomes a no-op without the invariant or a hydration-mismatch console error.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.