Skip to content

Commit fb0f069

Browse files
authored
fix(router-core): Route with server function as loader that throws notFound crashes route on HMR #5763 (#5890)
1 parent 30eff51 commit fb0f069

File tree

3 files changed

+206
-2
lines changed

3 files changed

+206
-2
lines changed

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,109 @@ describe('invalidate', () => {
13751375
expect(match.invalid).toBe(false)
13761376
})
13771377
})
1378+
1379+
/**
1380+
* Regression test:
1381+
* - When a route loader throws `notFound()`, the match enters a `'notFound'` status.
1382+
* - After an HMR-style `router.invalidate({ filter })`, the router should reset that match
1383+
* back to `'pending'`, re-run its loader, and still render the route's `notFoundComponent`.
1384+
*/
1385+
it('re-runs loaders that throw notFound() when invalidated via HMR filter', async () => {
1386+
const history = createMemoryHistory({
1387+
initialEntries: ['/hmr-not-found'],
1388+
})
1389+
const loader = vi.fn(() => {
1390+
throw notFound()
1391+
})
1392+
1393+
const rootRoute = createRootRoute({
1394+
component: () => <Outlet />,
1395+
})
1396+
1397+
const hmrRoute = createRoute({
1398+
getParentRoute: () => rootRoute,
1399+
path: '/hmr-not-found',
1400+
loader,
1401+
component: () => <div data-testid="hmr-route">Route</div>,
1402+
notFoundComponent: () => (
1403+
<div data-testid="hmr-route-not-found">Route Not Found</div>
1404+
),
1405+
})
1406+
1407+
const router = createRouter({
1408+
routeTree: rootRoute.addChildren([hmrRoute]),
1409+
history,
1410+
})
1411+
1412+
render(<RouterProvider router={router} />)
1413+
1414+
await act(() => router.load())
1415+
1416+
expect(await screen.findByTestId('hmr-route-not-found')).toBeInTheDocument()
1417+
const initialCalls = loader.mock.calls.length
1418+
expect(initialCalls).toBeGreaterThan(0)
1419+
1420+
await act(() =>
1421+
router.invalidate({
1422+
filter: (match) => match.routeId === hmrRoute.id,
1423+
}),
1424+
)
1425+
1426+
expect(loader).toHaveBeenCalledTimes(initialCalls + 1)
1427+
expect(await screen.findByTestId('hmr-route-not-found')).toBeInTheDocument()
1428+
expect(screen.queryByTestId('hmr-route')).not.toBeInTheDocument()
1429+
})
1430+
1431+
/**
1432+
* Regression test:
1433+
* - When a route loader returns `notFound()`, the route's `notFoundComponent` should render.
1434+
* - After a global `router.invalidate()`, the route should re-run its loader and continue
1435+
* to render the same `notFoundComponent` instead of falling back to a generic error boundary.
1436+
*/
1437+
it('keeps rendering a route notFoundComponent when loader returns notFound() after invalidate', async () => {
1438+
const history = createMemoryHistory({
1439+
initialEntries: ['/loader-not-found'],
1440+
})
1441+
const loader = vi.fn(() => notFound())
1442+
1443+
const rootRoute = createRootRoute({
1444+
component: () => <Outlet />,
1445+
})
1446+
1447+
const loaderRoute = createRoute({
1448+
getParentRoute: () => rootRoute,
1449+
path: '/loader-not-found',
1450+
loader,
1451+
component: () => <div data-testid="loader-route">Route</div>,
1452+
notFoundComponent: () => (
1453+
<div data-testid="loader-not-found-component">Route Not Found</div>
1454+
),
1455+
})
1456+
1457+
const router = createRouter({
1458+
routeTree: rootRoute.addChildren([loaderRoute]),
1459+
history,
1460+
})
1461+
1462+
render(<RouterProvider router={router} />)
1463+
1464+
await act(() => router.load())
1465+
1466+
const notFoundElement = await screen.findByTestId(
1467+
'loader-not-found-component',
1468+
)
1469+
expect(notFoundElement).toBeInTheDocument()
1470+
const initialCalls = loader.mock.calls.length
1471+
expect(initialCalls).toBeGreaterThan(0)
1472+
1473+
await act(() => router.invalidate())
1474+
1475+
expect(loader).toHaveBeenCalledTimes(initialCalls + 1)
1476+
expect(
1477+
await screen.findByTestId('loader-not-found-component'),
1478+
).toBeInTheDocument()
1479+
expect(screen.queryByTestId('loader-route')).not.toBeInTheDocument()
1480+
})
13781481
})
13791482

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

packages/router-core/src/router.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,9 +2130,18 @@ export class RouterCore<
21302130
loadedAt: Date.now(),
21312131
matches: newMatches,
21322132
pendingMatches: undefined,
2133+
/**
2134+
* When committing new matches, cache any exiting matches that are still usable.
2135+
* Routes that resolved with `status: 'error'` or `status: 'notFound'` are
2136+
* deliberately excluded from `cachedMatches` so that subsequent invalidations
2137+
* or reloads re-run their loaders instead of reusing the failed/not-found data.
2138+
*/
21332139
cachedMatches: [
21342140
...s.cachedMatches,
2135-
...exitingMatches.filter((d) => d.status !== 'error'),
2141+
...exitingMatches.filter(
2142+
(d) =>
2143+
d.status !== 'error' && d.status !== 'notFound',
2144+
),
21362145
],
21372146
}
21382147
})
@@ -2304,6 +2313,14 @@ export class RouterCore<
23042313
)
23052314
}
23062315

2316+
/**
2317+
* Invalidate the current matches and optionally force them back into a pending state.
2318+
*
2319+
* - Marks all matches that pass the optional `filter` as `invalid: true`.
2320+
* - If `forcePending` is true, or a match is currently in `'error'` or `'notFound'` status,
2321+
* its status is reset to `'pending'` and its `error` cleared so that the loader is re-run
2322+
* on the next `load()` call (eg. after HMR or a manual invalidation).
2323+
*/
23072324
invalidate: InvalidateFn<
23082325
RouterCore<
23092326
TRouteTree,
@@ -2318,7 +2335,9 @@ export class RouterCore<
23182335
return {
23192336
...d,
23202337
invalid: true,
2321-
...(opts?.forcePending || d.status === 'error'
2338+
...(opts?.forcePending ||
2339+
d.status === 'error' ||
2340+
d.status === 'notFound'
23222341
? ({ status: 'pending', error: undefined } as const)
23232342
: undefined),
23242343
}

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,88 @@ describe('invalidate', () => {
10171017
expect(match.invalid).toBe(false)
10181018
})
10191019
})
1020+
1021+
it('re-runs loaders that throw notFound() when invalidated via HMR filter', async () => {
1022+
const history = createMemoryHistory({
1023+
initialEntries: ['/hmr-not-found'],
1024+
})
1025+
const loader = vi.fn(() => {
1026+
throw notFound()
1027+
})
1028+
1029+
const rootRoute = createRootRoute({
1030+
component: () => <Outlet />,
1031+
})
1032+
1033+
const hmrRoute = createRoute({
1034+
getParentRoute: () => rootRoute,
1035+
path: '/hmr-not-found',
1036+
loader,
1037+
component: () => <div data-testid="hmr-route">Route</div>,
1038+
notFoundComponent: () => (
1039+
<div data-testid="hmr-route-not-found">Route Not Found</div>
1040+
),
1041+
})
1042+
1043+
const router = createRouter({
1044+
routeTree: rootRoute.addChildren([hmrRoute]),
1045+
history,
1046+
})
1047+
1048+
render(() => <RouterProvider router={router} />)
1049+
await router.load()
1050+
1051+
await screen.findByTestId('hmr-route-not-found')
1052+
const initialCalls = loader.mock.calls.length
1053+
expect(initialCalls).toBeGreaterThan(0)
1054+
1055+
await router.invalidate({
1056+
filter: (match) => match.routeId === hmrRoute.id,
1057+
})
1058+
1059+
await waitFor(() => expect(loader).toHaveBeenCalledTimes(initialCalls + 1))
1060+
await screen.findByTestId('hmr-route-not-found')
1061+
expect(screen.queryByTestId('hmr-route')).not.toBeInTheDocument()
1062+
})
1063+
1064+
it('keeps rendering a route notFoundComponent when loader returns notFound() after invalidate', async () => {
1065+
const history = createMemoryHistory({
1066+
initialEntries: ['/loader-not-found'],
1067+
})
1068+
const loader = vi.fn(() => notFound())
1069+
1070+
const rootRoute = createRootRoute({
1071+
component: () => <Outlet />,
1072+
})
1073+
1074+
const loaderRoute = createRoute({
1075+
getParentRoute: () => rootRoute,
1076+
path: '/loader-not-found',
1077+
loader,
1078+
component: () => <div data-testid="loader-route">Route</div>,
1079+
notFoundComponent: () => (
1080+
<div data-testid="loader-not-found-component">Route Not Found</div>
1081+
),
1082+
})
1083+
1084+
const router = createRouter({
1085+
routeTree: rootRoute.addChildren([loaderRoute]),
1086+
history,
1087+
})
1088+
1089+
render(() => <RouterProvider router={router} />)
1090+
await router.load()
1091+
1092+
await screen.findByTestId('loader-not-found-component')
1093+
const initialCalls = loader.mock.calls.length
1094+
expect(initialCalls).toBeGreaterThan(0)
1095+
1096+
await router.invalidate()
1097+
1098+
await waitFor(() => expect(loader).toHaveBeenCalledTimes(initialCalls + 1))
1099+
await screen.findByTestId('loader-not-found-component')
1100+
expect(screen.queryByTestId('loader-route')).not.toBeInTheDocument()
1101+
})
10201102
})
10211103

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

0 commit comments

Comments
 (0)