From c48d290ab4949d00df50abc28507ebd2dd0000e3 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 10 Apr 2026 19:57:55 +0200 Subject: [PATCH 1/2] fix(react-router): clear stale route errors on navigation fixes #7121 --- .changeset/ten-cougars-jump.md | 5 ++ packages/react-router/src/CatchBoundary.tsx | 29 +++--- .../tests/errorComponent.test.tsx | 89 ++++++++++++++++++- 3 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 .changeset/ten-cougars-jump.md diff --git a/.changeset/ten-cougars-jump.md b/.changeset/ten-cougars-jump.md new file mode 100644 index 00000000000..e411ed3dc9d --- /dev/null +++ b/.changeset/ten-cougars-jump.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +Fix a stale route error boundary state issue that could briefly render the next route's `errorComponent` after navigating away from a failed route. diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index 68197b2eb6e..a9eb7ea4de8 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -37,8 +37,15 @@ class CatchBoundaryImpl extends React.Component<{ onCatch?: (error: Error, errorInfo: ErrorInfo) => void }> { state = { error: null } as { error: Error | null; resetKey: string } - static getDerivedStateFromProps(props: any) { - return { resetKey: props.getResetKey() } + + static getDerivedStateFromProps(props: any, state: any) { + const resetKey = props.getResetKey() + + if (state.error && state.resetKey !== resetKey) { + return { resetKey, error: null } + } + + return { resetKey } } static getDerivedStateFromError(error: Error) { return { error } @@ -46,30 +53,14 @@ class CatchBoundaryImpl extends React.Component<{ reset() { this.setState({ error: null }) } - componentDidUpdate( - prevProps: Readonly<{ - getResetKey: () => string - children: (props: { error: any; reset: () => void }) => any - onCatch?: ((error: any, info: any) => void) | undefined - }>, - prevState: any, - ): void { - if (prevState.error && prevState.resetKey !== this.state.resetKey) { - this.reset() - } - } componentDidCatch(error: Error, errorInfo: ErrorInfo) { if (this.props.onCatch) { this.props.onCatch(error, errorInfo) } } render() { - // If the resetKey has changed, don't render the error return this.props.children({ - error: - this.state.resetKey !== this.props.getResetKey() - ? null - : this.state.error, + error: this.state.error, reset: () => { this.reset() }, diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 7835ca9e3a0..62233bb48dc 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { Link, @@ -169,5 +169,92 @@ describe.each([true, false])( }) }, ) + + describe('stale route errors do not leak after navigation', () => { + test.each([ + { + caller: 'loader' as const, + routeOptions: { + loader: throwFn, + }, + }, + { + caller: 'render' as const, + routeOptions: { + component: function RenderErrorComponent() { + throwFn() + }, + }, + }, + ])( + 'navigating away from a $caller error does not call the next route errorComponent', + async ({ routeOptions }) => { + const rootRoute = createRootRoute() + const indexErrorComponent = vi.fn(() =>
Index error
) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+
Index route content
+ link to about +
+ ) + }, + errorComponent: indexErrorComponent, + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: function About() { + return
About route content
+ }, + errorComponent: isUsingLazyError ? undefined : MyErrorComponent, + ...routeOptions, + }) + + if (isUsingLazyError) { + aboutRoute.lazy(() => + Promise.resolve( + createLazyRoute('/about')({ + errorComponent: MyErrorComponent, + }), + ), + ) + } + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + + const router = createRouter({ + routeTree, + history, + }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + await act(() => fireEvent.click(linkToAbout)) + + expect( + await screen.findByText('Error: error thrown', undefined, { + timeout: 1500, + }), + ).toBeInTheDocument() + + await act(() => router.navigate({ to: '/' })) + + expect( + await screen.findByText('Index route content'), + ).toBeInTheDocument() + expect(indexErrorComponent).not.toHaveBeenCalled() + }, + ) + }) }, ) From 15d2903e4bd51664b6c94fd9721adfa879be0fb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:26:33 +0000 Subject: [PATCH 2/2] fix(react-router): use proper types in getDerivedStateFromProps Agent-Logs-Url: https://github.com/TanStack/router/sessions/1367dbce-0eb5-4a4b-8fbf-995ce8479c3c Co-authored-by: schiller-manuel <6340397+schiller-manuel@users.noreply.github.com> --- packages/react-router/src/CatchBoundary.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index a9eb7ea4de8..46e4106aeb5 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -36,9 +36,12 @@ class CatchBoundaryImpl extends React.Component<{ }) => React.ReactNode onCatch?: (error: Error, errorInfo: ErrorInfo) => void }> { - state = { error: null } as { error: Error | null; resetKey: string } + state = { error: null } as { error: Error | null; resetKey?: string | number } - static getDerivedStateFromProps(props: any, state: any) { + static getDerivedStateFromProps( + props: { getResetKey: () => string | number }, + state: { resetKey?: string | number; error: Error | null }, + ) { const resetKey = props.getResetKey() if (state.error && state.resetKey !== resetKey) {