Skip to content

fix(react-router): prevent MatchInner from throwing undefined when a redirected match's loadPromise is cleared#7861

Open
gonzoblasco wants to merge 1 commit into
TanStack:mainfrom
gonzoblasco:fix/7753-redirect-loadPromise-race
Open

fix(react-router): prevent MatchInner from throwing undefined when a redirected match's loadPromise is cleared#7861
gonzoblasco wants to merge 1 commit into
TanStack:mainfrom
gonzoblasco:fix/7753-redirect-loadPromise-race

Conversation

@gonzoblasco

@gonzoblasco gonzoblasco commented Jul 20, 2026

Copy link
Copy Markdown

When an async beforeLoad throws redirect() and pending UI is enabled (defaultPendingMs: 0), a race can leave MatchInner rendering a match observed as status === "redirected" after the redirect navigation has already settled. At that point the match's loadPromise has been cleared (undefined), so MatchInner executes throw getMatchPromise(match, "loadPromise") — which evaluates to throw undefined.

The thrown undefined escapes CatchBoundary because its render callback checks truthiness of the error (if (error)undefined is falsy). With React 19 the error reaches the root, the whole tree is unmounted, and the user gets a permanent white screen with Uncaught undefined in the console.

The fix (two parts):

  1. MatchInner (client + server branches): guard the throw so a cleared loadPromise (undefined) is not thrown. The redirect has already been processed by the router at that point — React can safely render the component until the match store updates with the new route.

  2. CatchBoundaryImpl: use a hasError sentinel instead of relying on error truthiness. This is defense in depth — if something else ever throws a falsy value, the boundary catches it instead of letting it unmount the app.

Testing:

  • Added 2 tests that reproduce the race: a single async beforeLoad redirect and a chain of two async redirects, both with defaultPendingMs: 0 to force pending publication mid-chain.
  • All 919 existing tests pass (49 test files, 0 failures).

Fixes #7753

…redirected match's loadPromise is cleared

When an async beforeLoad throws redirect() and pending UI is enabled
(defaultPendingMs: 0), a race can occur where MatchInner re-renders with
status === 'redirected' after the loadPromise has already been resolved
and cleared by load-matches.ts. The thrown undefined escapes CatchBoundary
(because its render callback checks truthiness of the error) and unmounts
the entire app with a white screen.

Fix: guard the throw in both the client and server MatchInner branches
so that a cleared loadPromise (undefined) is not thrown. The redirect
has already been processed by the router at that point, so React can
safely render the component until the match store updates.

Also hardens CatchBoundaryImpl to use a hasError sentinel instead of
relying on error truthiness, providing defense in depth against falsy
thrown values.

Fixes TanStack#7753
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The changes update redirect handling to avoid throwing cleared promises, make CatchBoundary track error presence independently from error values, and add regression tests for delayed and chained redirects with pending UI.

Changes

Redirect race containment

Layer / File(s) Summary
Redirect and boundary race fix
packages/react-router/src/CatchBoundary.tsx, packages/react-router/src/Match.tsx
CatchBoundary tracks hasError, and redirected matches throw loadPromise only when it exists.
Redirect race regression coverage
packages/react-router/tests/issue-7753-redirect-loadPromise-race.test.tsx
Tests cover delayed redirects and chained redirects with pending UI, verifying final rendering and no console errors.

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

Possibly related issues

Possibly related PRs

  • TanStack/router#7812 — Covers related redirect-with-pending race scenarios and no-uncaught-error behavior.

Suggested labels: package: react-router

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #7753 by guarding the cleared loadPromise path and making CatchBoundary contain falsy thrown values, with regression tests added.
Out of Scope Changes check ✅ Passed The PR stays focused on the redirect race fix and regression tests, with no unrelated code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically matches the main fix: preventing MatchInner from throwing undefined when a redirected match loses its loadPromise.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/react-router/src/Match.tsx (1)

442-448: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against throwing undefined for all pending promises. The root cause fixed by this PR for the redirected state (a cleared promise leading to throw undefined and crashing the app) can also occur for _displayPending, _forcePending, and the pending status if their respective promises resolve and clear before the stale render executes.

Conditionally check if the promise exists before throwing it across all these branches:

  • packages/react-router/src/Match.tsx#L442-L448: Assign displayPendingPromise and minPendingPromise to local variables and only throw if truthy.
  • packages/react-router/src/Match.tsx#L472-L473: Guard the loadPromise throw for match.status === 'pending'.
  • packages/react-router/src/Match.tsx#L340-L350: Apply the same conditional throwing logic for the server-side equivalents.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-router/src/Match.tsx` around lines 442 - 448, Guard every
pending-promise throw in Match.tsx against cleared promises: in the client
branches for _displayPending and _forcePending, assign displayPendingPromise and
minPendingPromise to locals and throw only when truthy; also guard loadPromise
for match.status === 'pending'. Apply the same conditional throwing logic to the
server-side equivalents in packages/react-router/src/Match.tsx lines 340-350,
while preserving existing status handling.
packages/react-router/src/CatchBoundary.tsx (1)

47-58: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Update getDerivedStateFromProps to use and reset hasError.

The newly added hasError state was missed in getDerivedStateFromProps. This causes two issues:

  1. Broken reset for falsy errors: If a falsy error was caught, state.error is falsy, so the if (state.error && ...) condition fails and the boundary will never reset when resetKey changes.
  2. Stale state: When the boundary does reset for a truthy error, it leaves hasError: true in the state because it only returns { resetKey, error: null }.

Update the inline type and logic to correctly depend on and reset hasError.

🐛 Proposed fix
   static getDerivedStateFromProps(
     props: { getResetKey: () => string | number },
-    state: { resetKey?: string | number; error: Error | null },
+    state: { resetKey?: string | number; error: Error | null; hasError: boolean },
   ) {
     const resetKey = props.getResetKey()
 
-    if (state.error && state.resetKey !== resetKey) {
-      return { resetKey, error: null }
+    if (state.hasError && state.resetKey !== resetKey) {
+      return { resetKey, error: null, hasError: false }
     }
 
     return { resetKey }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-router/src/CatchBoundary.tsx` around lines 47 - 58, Update
CatchBoundary.getDerivedStateFromProps to include hasError in its inline state
type and use it, rather than state.error, to detect whether a reset is needed
when resetKey changes. When resetting, return resetKey, error: null, and
hasError: false; preserve the normal resetKey-only update when no reset is
required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/react-router/src/CatchBoundary.tsx`:
- Around line 47-58: Update CatchBoundary.getDerivedStateFromProps to include
hasError in its inline state type and use it, rather than state.error, to detect
whether a reset is needed when resetKey changes. When resetting, return
resetKey, error: null, and hasError: false; preserve the normal resetKey-only
update when no reset is required.

In `@packages/react-router/src/Match.tsx`:
- Around line 442-448: Guard every pending-promise throw in Match.tsx against
cleared promises: in the client branches for _displayPending and _forcePending,
assign displayPendingPromise and minPendingPromise to locals and throw only when
truthy; also guard loadPromise for match.status === 'pending'. Apply the same
conditional throwing logic to the server-side equivalents in
packages/react-router/src/Match.tsx lines 340-350, while preserving existing
status handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b47e02fa-80e3-4f41-87a1-12c8785942fa

📥 Commits

Reviewing files that changed from the base of the PR and between edf5575 and c7f9816.

📒 Files selected for processing (3)
  • packages/react-router/src/CatchBoundary.tsx
  • packages/react-router/src/Match.tsx
  • packages/react-router/tests/issue-7753-redirect-loadPromise-race.test.tsx

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.

MatchInner can throw undefined when a redirected match's loadPromise was already cleared — escapes CatchBoundary and unmounts the whole app

1 participant