From 3cd2f158275f991c1874e2b33a036d5fef122d3a Mon Sep 17 00:00:00 2001 From: dt_emmy Date: Mon, 17 Nov 2025 19:15:53 +0100 Subject: [PATCH 1/5] fix: #5763 --- packages/react-router/tests/router.test.tsx | 95 +++++++++++++++++++++ packages/router-core/src/router.ts | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index de0826020d..1ea5a81017 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1375,6 +1375,101 @@ 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 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() + }) + + 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 770a526607..c9d80fb699 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2318,7 +2318,7 @@ 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), } From 68e25a90e0c21d201710cd10cec50cb503fd0cee Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:32:45 +0000 Subject: [PATCH 2/5] ci: apply automated fixes --- packages/react-router/tests/router.test.tsx | 8 ++------ packages/router-core/src/router.ts | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 1ea5a81017..e179231ba1 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1407,9 +1407,7 @@ describe('invalidate', () => { await act(() => router.load()) - expect( - await screen.findByTestId('hmr-route-not-found'), - ).toBeInTheDocument() + expect(await screen.findByTestId('hmr-route-not-found')).toBeInTheDocument() const initialCalls = loader.mock.calls.length expect(initialCalls).toBeGreaterThan(0) @@ -1420,9 +1418,7 @@ describe('invalidate', () => { ) expect(loader).toHaveBeenCalledTimes(initialCalls + 1) - expect( - await screen.findByTestId('hmr-route-not-found'), - ).toBeInTheDocument() + expect(await screen.findByTestId('hmr-route-not-found')).toBeInTheDocument() expect(screen.queryByTestId('hmr-route')).not.toBeInTheDocument() }) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index c9d80fb699..811ddc3859 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2318,7 +2318,9 @@ export class RouterCore< return { ...d, invalid: true, - ...(opts?.forcePending || d.status === 'error' || d.status === 'notFound' + ...(opts?.forcePending || + d.status === 'error' || + d.status === 'notFound' ? ({ status: 'pending', error: undefined } as const) : undefined), } From 2794cf048610c4336796970714478532a4dca48a Mon Sep 17 00:00:00 2001 From: dt_emmy Date: Tue, 18 Nov 2025 10:55:18 +0100 Subject: [PATCH 3/5] chore: added docstrings and resolved potential issue --- packages/react-router/tests/router.test.tsx | 12 ++++++++++++ packages/router-core/src/router.ts | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 1ea5a81017..cdb78c9fe5 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1376,6 +1376,12 @@ describe('invalidate', () => { }) }) + /** + * 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'], @@ -1426,6 +1432,12 @@ describe('invalidate', () => { 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'], diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index c9d80fb699..48a1a77b91 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2130,9 +2130,15 @@ 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 +2310,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, From abe3b86b02e173ac9f7e8a6be3c5df3ed01e0b75 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:38:27 +0000 Subject: [PATCH 4/5] ci: apply automated fixes --- packages/router-core/src/router.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2707c90eb7..e11f21cec1 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2138,7 +2138,10 @@ export class RouterCore< */ cachedMatches: [ ...s.cachedMatches, - ...exitingMatches.filter((d) => d.status !== 'error' && d.status !== 'notFound'), + ...exitingMatches.filter( + (d) => + d.status !== 'error' && d.status !== 'notFound', + ), ], } }) From c4f892b40108df9b648ee86d2f51b4e81fd208db Mon Sep 17 00:00:00 2001 From: dt_emmy Date: Tue, 18 Nov 2025 14:02:58 +0100 Subject: [PATCH 5/5] chore: added similar test to solid-router in solid-router package --- packages/solid-router/tests/router.test.tsx | 82 +++++++++++++++++++++ 1 file changed, 82 insertions(+) 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', () => {