fix(react-router): prevent MatchInner from throwing undefined when a redirected match's loadPromise is cleared#7861
Conversation
…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
📝 WalkthroughWalkthroughThe changes update redirect handling to avoid throwing cleared promises, make ChangesRedirect race containment
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winGuard against throwing
undefinedfor all pending promises. The root cause fixed by this PR for theredirectedstate (a cleared promise leading tothrow undefinedand crashing the app) can also occur for_displayPending,_forcePending, and thependingstatus 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: AssigndisplayPendingPromiseandminPendingPromiseto local variables and only throw if truthy.packages/react-router/src/Match.tsx#L472-L473: Guard theloadPromisethrow formatch.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 winUpdate
getDerivedStateFromPropsto use and resethasError.The newly added
hasErrorstate was missed ingetDerivedStateFromProps. This causes two issues:
- Broken reset for falsy errors: If a falsy error was caught,
state.erroris falsy, so theif (state.error && ...)condition fails and the boundary will never reset whenresetKeychanges.- Stale state: When the boundary does reset for a truthy error, it leaves
hasError: truein 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
📒 Files selected for processing (3)
packages/react-router/src/CatchBoundary.tsxpackages/react-router/src/Match.tsxpackages/react-router/tests/issue-7753-redirect-loadPromise-race.test.tsx
When an async
beforeLoadthrowsredirect()and pending UI is enabled (defaultPendingMs: 0), a race can leaveMatchInnerrendering a match observed asstatus === "redirected"after the redirect navigation has already settled. At that point the match'sloadPromisehas been cleared (undefined), soMatchInnerexecutesthrow getMatchPromise(match, "loadPromise")— which evaluates tothrow undefined.The thrown
undefinedescapesCatchBoundarybecause its render callback checks truthiness of the error (if (error)—undefinedis falsy). With React 19 the error reaches the root, the whole tree is unmounted, and the user gets a permanent white screen withUncaught undefinedin the console.The fix (two parts):
MatchInner(client + server branches): guard thethrowso a clearedloadPromise(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.CatchBoundaryImpl: use ahasErrorsentinel 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:
beforeLoadredirect and a chain of two async redirects, both withdefaultPendingMs: 0to force pending publication mid-chain.Fixes #7753