From 61fa6274d9c9106d57b962b71d40028ec41f5982 Mon Sep 17 00:00:00 2001 From: Hossein Date: Fri, 3 Jul 2026 18:17:51 +0200 Subject: [PATCH] fix(react-router): Catch thrown nullish value and prevent nullish loadPromise from being thrown The issue happens in circumstances where match.status === 'redirected' but the loadPromise is nulled. Reproduction is possible by using https://github.com/joseinaqa/redirect-race-app and after this change does not anymore. --- packages/react-router/src/CatchBoundary.tsx | 8 ++++++-- packages/react-router/src/Match.tsx | 22 ++++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index 98743f9447..5134408194 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -53,14 +53,18 @@ class CatchBoundaryImpl extends React.Component<{ return { resetKey } } static getDerivedStateFromError(error: Error) { - return { error } + return { error: error ?? new Error('A nullish value was thrown') } } reset() { this.setState({ error: null }) } componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (error == null && this.state.error) { + this.state.error.stack = + errorInfo.componentStack ?? this.state.error.stack + } if (this.props.onCatch) { - this.props.onCatch(error, errorInfo) + this.props.onCatch(this.state.error ?? error, errorInfo) } } render() { diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 5de5546aeb..76b2d05457 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -287,6 +287,8 @@ function OnRendered() { return null } +const permaPendingPromise = new Promise(() => {}) + export const MatchInner = React.memo(function MatchInnerImpl({ matchId, }: { @@ -338,15 +340,17 @@ export const MatchInner = React.memo(function MatchInnerImpl({ const out = Comp ? : if (match._displayPending) { - throw getMatchPromise(match, 'displayPendingPromise') + throw ( + getMatchPromise(match, 'displayPendingPromise') ?? permaPendingPromise + ) } if (match._forcePending) { - throw getMatchPromise(match, 'minPendingPromise') + throw getMatchPromise(match, 'minPendingPromise') ?? permaPendingPromise } if (match.status === 'pending') { - throw getMatchPromise(match, 'loadPromise') + throw getMatchPromise(match, 'loadPromise') ?? permaPendingPromise } if (match.status === 'notFound') { @@ -368,7 +372,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ invariant() } - throw getMatchPromise(match, 'loadPromise') + throw getMatchPromise(match, 'loadPromise') ?? permaPendingPromise } if (match.status === 'error') { @@ -435,11 +439,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }, [key, route.options.component, router.options.defaultComponent]) if (match._displayPending) { - throw getMatchPromise(match, 'displayPendingPromise') + throw getMatchPromise(match, 'displayPendingPromise') ?? permaPendingPromise } if (match._forcePending) { - throw getMatchPromise(match, 'minPendingPromise') + throw getMatchPromise(match, 'minPendingPromise') ?? permaPendingPromise } // see also hydrate() in packages/router-core/src/ssr/ssr-client.ts @@ -464,7 +468,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } } } - throw getMatchPromise(match, 'loadPromise') + throw getMatchPromise(match, 'loadPromise') ?? permaPendingPromise } if (match.status === 'notFound') { @@ -491,7 +495,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ invariant() } - throw getMatchPromise(match, 'loadPromise') + throw getMatchPromise(match, 'loadPromise') ?? permaPendingPromise } if (match.status === 'error') { @@ -516,7 +520,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ ) } - throw match.error + throw match.error ?? new Error('Unknown match error!') } return out