Skip to content

Commit 16f6892

Browse files
fix(router-core): avoid intermediate success state for async notFound (#7184)
1 parent 73cd569 commit 16f6892

3 files changed

Lines changed: 94 additions & 3 deletions

File tree

.changeset/clean-wombats-sleep.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/router-core': patch
3+
---
4+
5+
Fix async loaders that throw or return `notFound()` so they do not briefly mark the match as `success` before the final not-found boundary is resolved.
6+
7+
This prevents route components from rendering with missing loader data during navigation when React observes the intermediate match state before not-found finalization completes.

packages/react-router/tests/router.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,6 +1525,88 @@ describe('invalidate', () => {
15251525
).toBeInTheDocument()
15261526
expect(screen.queryByTestId('loader-route')).not.toBeInTheDocument()
15271527
})
1528+
1529+
it('does not render the route component while async loader notFound is waiting for later matches to settle', async () => {
1530+
const history = createMemoryHistory({
1531+
initialEntries: ['/parent/child/grandchild'],
1532+
})
1533+
1534+
const createControlledPromise = () => {
1535+
let resolve!: () => void
1536+
const promise = new Promise<void>((r) => {
1537+
resolve = r
1538+
})
1539+
1540+
return { promise, resolve }
1541+
}
1542+
1543+
const grandchildLoader = createControlledPromise()
1544+
1545+
const rootRoute = createRootRoute({
1546+
component: () => <Outlet />,
1547+
notFoundComponent: () => (
1548+
<div data-testid="root-not-found">Root Not Found</div>
1549+
),
1550+
})
1551+
1552+
const parentRoute = createRoute({
1553+
getParentRoute: () => rootRoute,
1554+
path: '/parent',
1555+
component: () => <Outlet />,
1556+
notFoundComponent: () => (
1557+
<div data-testid="parent-not-found">Parent Not Found</div>
1558+
),
1559+
})
1560+
1561+
const childRoute = createRoute({
1562+
getParentRoute: () => parentRoute,
1563+
path: '/child',
1564+
loader: async () => {
1565+
await Promise.resolve()
1566+
throw notFound()
1567+
},
1568+
component: () => (
1569+
<div data-testid="child-component">
1570+
Child component should not render
1571+
</div>
1572+
),
1573+
})
1574+
1575+
const grandchildRoute = createRoute({
1576+
getParentRoute: () => childRoute,
1577+
path: '/grandchild',
1578+
loader: () => grandchildLoader.promise,
1579+
component: () => <div data-testid="grandchild-component">Grandchild</div>,
1580+
})
1581+
1582+
const router = createRouter({
1583+
routeTree: rootRoute.addChildren([
1584+
parentRoute.addChildren([childRoute.addChildren([grandchildRoute])]),
1585+
]),
1586+
history,
1587+
defaultPendingMs: 0,
1588+
defaultPendingComponent: () => (
1589+
<div data-testid="pending">Loading...</div>
1590+
),
1591+
})
1592+
1593+
render(<RouterProvider router={router} />)
1594+
1595+
await waitFor(() => {
1596+
expect(router.state.location.pathname).toBe('/parent/child/grandchild')
1597+
expect(router.state.matches[0]?.routeId).toBe(rootRoute.id)
1598+
expect(router.state.matches[1]?.routeId).toBe(parentRoute.id)
1599+
expect(router.state.matches[2]?.routeId).toBe(childRoute.id)
1600+
})
1601+
1602+
expect(screen.queryByTestId('child-component')).not.toBeInTheDocument()
1603+
expect(screen.queryByTestId('parent-not-found')).not.toBeInTheDocument()
1604+
1605+
grandchildLoader.resolve()
1606+
1607+
expect(await screen.findByTestId('parent-not-found')).toBeInTheDocument()
1608+
expect(screen.queryByTestId('child-component')).not.toBeInTheDocument()
1609+
})
15281610
})
15291611

15301612
describe('search params in URL', () => {

packages/router-core/src/load-matches.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,11 @@ const handleRedirectAndNotFound = (
136136
...prev,
137137
status: isRedirect(err)
138138
? 'redirected'
139-
: prev.status === 'pending'
140-
? 'success'
141-
: prev.status,
139+
: isNotFound(err)
140+
? 'notFound'
141+
: prev.status === 'pending'
142+
? 'success'
143+
: prev.status,
142144
context: buildMatchContext(inner, match.index),
143145
isFetching: false,
144146
error: err,

0 commit comments

Comments
 (0)