diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx
index de0826020d..32fc3efa07 100644
--- a/packages/react-router/tests/router.test.tsx
+++ b/packages/react-router/tests/router.test.tsx
@@ -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: () => ,
+ })
+
+ const hmrRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/hmr-not-found',
+ loader,
+ component: () =>
Route
,
+ notFoundComponent: () => (
+ Route Not Found
+ ),
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([hmrRoute]),
+ history,
+ })
+
+ render()
+
+ 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: () => ,
+ })
+
+ const loaderRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/loader-not-found',
+ loader,
+ component: () => Route
,
+ notFoundComponent: () => (
+ Route Not Found
+ ),
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([loaderRoute]),
+ history,
+ })
+
+ render()
+
+ 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', () => {
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index c19d149d26..3b8f61ddb1 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -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',
+ ),
],
}
})
@@ -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,
@@ -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),
}
diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx
index 6e515d95fe..524ee1910b 100644
--- a/packages/solid-router/tests/router.test.tsx
+++ b/packages/solid-router/tests/router.test.tsx
@@ -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: () => ,
+ })
+
+ const hmrRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/hmr-not-found',
+ loader,
+ component: () => Route
,
+ notFoundComponent: () => (
+ Route Not Found
+ ),
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([hmrRoute]),
+ history,
+ })
+
+ render(() => )
+ 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: () => ,
+ })
+
+ const loaderRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/loader-not-found',
+ loader,
+ component: () => Route
,
+ notFoundComponent: () => (
+ Route Not Found
+ ),
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([loaderRoute]),
+ history,
+ })
+
+ render(() => )
+ 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', () => {