Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-cougars-jump.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 14 additions & 20 deletions packages/react-router/src/CatchBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,40 +36,34 @@ class CatchBoundaryImpl extends React.Component<{
}) => React.ReactNode
onCatch?: (error: Error, errorInfo: ErrorInfo) => void
}> {
state = { error: null } as { error: Error | null; resetKey: string }
static getDerivedStateFromProps(props: any) {
return { resetKey: props.getResetKey() }
state = { error: null } as { error: Error | null; resetKey?: string | number }

static getDerivedStateFromProps(
props: { getResetKey: () => string | number },
state: { resetKey?: string | number; error: Error | null },
) {
const resetKey = props.getResetKey()

if (state.error && state.resetKey !== resetKey) {
return { resetKey, error: null }
}

return { resetKey }
}
static getDerivedStateFromError(error: Error) {
return { error }
}
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()
},
Expand Down
89 changes: 88 additions & 1 deletion packages/react-router/tests/errorComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(() => <div>Index error</div>)

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: function Home() {
return (
<div>
<div>Index route content</div>
<Link to="/about">link to about</Link>
</div>
)
},
errorComponent: indexErrorComponent,
})

const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: function About() {
return <div>About route content</div>
},
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(<RouterProvider router={router} />)

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()
},
)
})
},
)
Loading