Skip to content
Open
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
103 changes: 103 additions & 0 deletions packages/react-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,109 @@ describe('invalidate', () => {
expect(match.invalid).toBe(false)
})
})

/**
* Regression test:
* - When a route loader throws `notFound()`, the match enters a `'notFound'` status.
* - After an HMR-style `router.invalidate({ filter })`, the router should reset that match
* back to `'pending'`, re-run its loader, and still render the route's `notFoundComponent`.
*/
it('re-runs loaders that throw notFound() when invalidated via HMR filter', async () => {
const history = createMemoryHistory({
initialEntries: ['/hmr-not-found'],
})
const loader = vi.fn(() => {
throw notFound()
})

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const hmrRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/hmr-not-found',
loader,
component: () => <div data-testid="hmr-route">Route</div>,
notFoundComponent: () => (
<div data-testid="hmr-route-not-found">Route Not Found</div>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([hmrRoute]),
history,
})

render(<RouterProvider router={router} />)

await act(() => router.load())

expect(await screen.findByTestId('hmr-route-not-found')).toBeInTheDocument()
const initialCalls = loader.mock.calls.length
expect(initialCalls).toBeGreaterThan(0)

await act(() =>
router.invalidate({
filter: (match) => match.routeId === hmrRoute.id,
}),
)

expect(loader).toHaveBeenCalledTimes(initialCalls + 1)
expect(await screen.findByTestId('hmr-route-not-found')).toBeInTheDocument()
expect(screen.queryByTestId('hmr-route')).not.toBeInTheDocument()
})

/**
* Regression test:
* - When a route loader returns `notFound()`, the route's `notFoundComponent` should render.
* - After a global `router.invalidate()`, the route should re-run its loader and continue
* to render the same `notFoundComponent` instead of falling back to a generic error boundary.
*/
it('keeps rendering a route notFoundComponent when loader returns notFound() after invalidate', async () => {
const history = createMemoryHistory({
initialEntries: ['/loader-not-found'],
})
const loader = vi.fn(() => notFound())

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const loaderRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/loader-not-found',
loader,
component: () => <div data-testid="loader-route">Route</div>,
notFoundComponent: () => (
<div data-testid="loader-not-found-component">Route Not Found</div>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([loaderRoute]),
history,
})

render(<RouterProvider router={router} />)

await act(() => router.load())

const notFoundElement = await screen.findByTestId(
'loader-not-found-component',
)
expect(notFoundElement).toBeInTheDocument()
const initialCalls = loader.mock.calls.length
expect(initialCalls).toBeGreaterThan(0)

await act(() => router.invalidate())

expect(loader).toHaveBeenCalledTimes(initialCalls + 1)
expect(
await screen.findByTestId('loader-not-found-component'),
).toBeInTheDocument()
expect(screen.queryByTestId('loader-route')).not.toBeInTheDocument()
})
})

describe('search params in URL', () => {
Expand Down
23 changes: 21 additions & 2 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2130,9 +2130,18 @@ export class RouterCore<
loadedAt: Date.now(),
matches: newMatches,
pendingMatches: undefined,
/**
* When committing new matches, cache any exiting matches that are still usable.
* Routes that resolved with `status: 'error'` or `status: 'notFound'` are
* deliberately excluded from `cachedMatches` so that subsequent invalidations
* or reloads re-run their loaders instead of reusing the failed/not-found data.
*/
cachedMatches: [
...s.cachedMatches,
...exitingMatches.filter((d) => d.status !== 'error'),
...exitingMatches.filter(
(d) =>
d.status !== 'error' && d.status !== 'notFound',
),
],
}
})
Expand Down Expand Up @@ -2304,6 +2313,14 @@ export class RouterCore<
)
}

/**
* Invalidate the current matches and optionally force them back into a pending state.
*
* - Marks all matches that pass the optional `filter` as `invalid: true`.
* - If `forcePending` is true, or a match is currently in `'error'` or `'notFound'` status,
* its status is reset to `'pending'` and its `error` cleared so that the loader is re-run
* on the next `load()` call (eg. after HMR or a manual invalidation).
*/
invalidate: InvalidateFn<
RouterCore<
TRouteTree,
Expand All @@ -2318,7 +2335,9 @@ export class RouterCore<
return {
...d,
invalid: true,
...(opts?.forcePending || d.status === 'error'
...(opts?.forcePending ||
d.status === 'error' ||
d.status === 'notFound'
? ({ status: 'pending', error: undefined } as const)
: undefined),
}
Expand Down
82 changes: 82 additions & 0 deletions packages/solid-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,88 @@ describe('invalidate', () => {
expect(match.invalid).toBe(false)
})
})

it('re-runs loaders that throw notFound() when invalidated via HMR filter', async () => {
const history = createMemoryHistory({
initialEntries: ['/hmr-not-found'],
})
const loader = vi.fn(() => {
throw notFound()
})

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const hmrRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/hmr-not-found',
loader,
component: () => <div data-testid="hmr-route">Route</div>,
notFoundComponent: () => (
<div data-testid="hmr-route-not-found">Route Not Found</div>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([hmrRoute]),
history,
})

render(() => <RouterProvider router={router} />)
await router.load()

await screen.findByTestId('hmr-route-not-found')
const initialCalls = loader.mock.calls.length
expect(initialCalls).toBeGreaterThan(0)

await router.invalidate({
filter: (match) => match.routeId === hmrRoute.id,
})

await waitFor(() => expect(loader).toHaveBeenCalledTimes(initialCalls + 1))
await screen.findByTestId('hmr-route-not-found')
expect(screen.queryByTestId('hmr-route')).not.toBeInTheDocument()
})

it('keeps rendering a route notFoundComponent when loader returns notFound() after invalidate', async () => {
const history = createMemoryHistory({
initialEntries: ['/loader-not-found'],
})
const loader = vi.fn(() => notFound())

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const loaderRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/loader-not-found',
loader,
component: () => <div data-testid="loader-route">Route</div>,
notFoundComponent: () => (
<div data-testid="loader-not-found-component">Route Not Found</div>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([loaderRoute]),
history,
})

render(() => <RouterProvider router={router} />)
await router.load()

await screen.findByTestId('loader-not-found-component')
const initialCalls = loader.mock.calls.length
expect(initialCalls).toBeGreaterThan(0)

await router.invalidate()

await waitFor(() => expect(loader).toHaveBeenCalledTimes(initialCalls + 1))
await screen.findByTestId('loader-not-found-component')
expect(screen.queryByTestId('loader-route')).not.toBeInTheDocument()
})
})

describe('search params in URL', () => {
Expand Down
Loading