diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx
index 98743f9447..5a2c393feb 100644
--- a/packages/react-router/src/CatchBoundary.tsx
+++ b/packages/react-router/src/CatchBoundary.tsx
@@ -38,7 +38,11 @@ class CatchBoundaryImpl extends React.Component<{
}) => React.ReactNode
onCatch?: (error: Error, errorInfo: ErrorInfo) => void
}> {
- state = { error: null } as { error: Error | null; resetKey?: string | number }
+ state = { error: null, hasError: false } as {
+ error: Error | null
+ hasError: boolean
+ resetKey?: string | number
+ }
static getDerivedStateFromProps(
props: { getResetKey: () => string | number },
@@ -53,10 +57,10 @@ class CatchBoundaryImpl extends React.Component<{
return { resetKey }
}
static getDerivedStateFromError(error: Error) {
- return { error }
+ return { error, hasError: true }
}
reset() {
- this.setState({ error: null })
+ this.setState({ error: null, hasError: false })
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (this.props.onCatch) {
@@ -65,7 +69,7 @@ class CatchBoundaryImpl extends React.Component<{
}
render() {
return this.props.children({
- error: this.state.error,
+ error: this.state.hasError ? this.state.error : null,
reset: () => {
this.reset()
},
diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx
index 5de5546aeb..a31937001d 100644
--- a/packages/react-router/src/Match.tsx
+++ b/packages/react-router/src/Match.tsx
@@ -368,7 +368,12 @@ export const MatchInner = React.memo(function MatchInnerImpl({
invariant()
}
- throw getMatchPromise(match, 'loadPromise')
+
+ const loadPromise = getMatchPromise(match, 'loadPromise')
+
+ if (loadPromise) {
+ throw loadPromise
+ }
}
if (match.status === 'error') {
@@ -491,7 +496,17 @@ export const MatchInner = React.memo(function MatchInnerImpl({
invariant()
}
- throw getMatchPromise(match, 'loadPromise')
+ const loadPromise = getMatchPromise(match, 'loadPromise')
+
+ // The loadPromise may have been cleared by the time this stale render
+ // runs (race: the redirect navigation settled before MatchInner
+ // re-rendered). Throwing undefined would escape CatchBoundary (falsy
+ // check) and unmount the whole app. Instead, let React render the
+ // component — the redirect has already been processed and the match
+ // will be replaced on the next store update.
+ if (loadPromise) {
+ throw loadPromise
+ }
}
if (match.status === 'error') {
diff --git a/packages/react-router/tests/issue-7753-redirect-loadPromise-race.test.tsx b/packages/react-router/tests/issue-7753-redirect-loadPromise-race.test.tsx
new file mode 100644
index 0000000000..83570582c2
--- /dev/null
+++ b/packages/react-router/tests/issue-7753-redirect-loadPromise-race.test.tsx
@@ -0,0 +1,123 @@
+import * as React from 'react'
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, test, vi } from 'vitest'
+
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ redirect,
+} from '../src'
+import { sleep } from './utils'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ cleanup()
+})
+
+// https://github.com/TanStack/router/issues/7753
+// MatchInner can throw undefined when a redirected match's loadPromise was
+// already cleared — escapes CatchBoundary and unmounts the whole app.
+//
+// The race: an async beforeLoad throws redirect(), pending UI renders
+// (defaultPendingMs: 0), and by the time MatchInner re-renders with
+// status === 'redirected', the loadPromise has been resolved and cleared
+// by load-matches.ts. Throwing undefined escapes CatchBoundary (falsy
+// check) and unmounts the app with a white screen.
+test('redirected match with cleared loadPromise does not throw undefined', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const rootRoute = createRootRoute({
+ component: () =>