Skip to content

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

Description

@zspetersen

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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions