From f9a2af0c88e470d028a9b056e17cbcb7b7ed2c03 Mon Sep 17 00:00:00 2001 From: leesb971204 Date: Tue, 20 Jan 2026 11:38:20 +0900 Subject: [PATCH 1/3] fix(router-core): fix handle AbortError logic Signed-off-by: leesb971204 --- packages/react-router/tests/loaders.test.tsx | 110 +++++++++++++++++++ packages/router-core/src/load-matches.ts | 31 ++++-- 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index fc9e87b0771..bed3f830ec4 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -824,3 +824,113 @@ test('cancelMatches after pending timeout', async () => { expect(fooPendingComponentOnMountMock).toHaveBeenCalled() expect(onAbortMock).toHaveBeenCalled() }) + +test('reproducer for #6388 - rapid navigation between parameterized routes should not trigger errorComponent', async () => { + const errorComponentRenderCount = vi.fn() + const onAbortMock = vi.fn() + const loaderCompleteMock = vi.fn() + + const rootRoute = createRootRoute({ + component: () => ( +
+ + Home + + + Param 1 + + + Param 2 + + +
+ ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home page
, + }) + + const paramRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/something/$id', + pendingMs: 0, + loader: async ({ params, abortController }) => { + const result = await new Promise<{ id: string; done: boolean }>( + (resolve, reject) => { + const timer = setTimeout(() => { + loaderCompleteMock(params.id) + resolve({ id: params.id, done: true }) + }, WAIT_TIME * 5) + + abortController.signal.addEventListener('abort', () => { + clearTimeout(timer) + onAbortMock(params.id) + reject(new DOMException('Aborted', 'AbortError')) + }) + }, + ) + + return result + }, + component: () => { + const data = paramRoute.useLoaderData() + return ( +
+ Param Component {data.id} {data.done ? 'Done' : 'Not done'} +
+ ) + }, + errorComponent: ({ error }) => { + errorComponentRenderCount(error) + return ( +
+ Error Component: {error.message} | Name: {error.name} +
+ ) + }, + pendingComponent: () =>
Pending
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, paramRoute]) + const router = createRouter({ + routeTree, + history, + defaultPreload: false, + }) + + render() + await act(() => router.latestLoadPromise) + + expect(await screen.findByTestId('home-page')).toBeInTheDocument() + + const param1Link = await screen.findByTestId('link-to-param-1') + fireEvent.click(param1Link) + + const param2Link = await screen.findByTestId('link-to-param-2') + fireEvent.click(param2Link) + + fireEvent.click(param1Link) + + await act(() => router.latestLoadPromise) + + expect(onAbortMock).toHaveBeenCalled() + expect(errorComponentRenderCount).not.toHaveBeenCalled() + expect(screen.queryByTestId('error-component')).not.toBeInTheDocument() + + const paramPage = await screen.findByTestId('param-page') + expect(paramPage).toBeInTheDocument() + expect(loaderCompleteMock).toHaveBeenCalled() +}) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index d9b1d622647..f20dbf7d531 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -703,12 +703,18 @@ const runLoader = async ( let error = e if ((error as any)?.name === 'AbortError') { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - status: prev.status === 'pending' ? 'success' : prev.status, - isFetching: false, - context: buildMatchContext(inner, index), - })) + const wasAbortedByNavigation = match.abortController?.signal.aborted === true + + if (!wasAbortedByNavigation) { + inner.updateMatch(matchId, (prev) => ({ + ...prev, + status: prev.status === 'pending' ? 'success' : prev.status, + isFetching: false, + })) + return + } + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.loaderPromise = undefined return } @@ -773,12 +779,17 @@ const loadRouteMatch = async ( return prevMatch } await prevMatch._nonReactive.loaderPromise - const match = inner.router.getMatch(matchId)! - const error = match._nonReactive.error || match.error + const matchAfterWait = inner.router.getMatch(matchId)! + const error = matchAfterWait._nonReactive.error || matchAfterWait.error if (error) { - handleRedirectAndNotFound(inner, match, error) + handleRedirectAndNotFound(inner, matchAfterWait, error) } - } else { + + if (matchAfterWait.status !== 'pending') { + return matchAfterWait + } + } + { // This is where all of the stale-while-revalidate magic happens const age = Date.now() - prevMatch.updatedAt From 484ef33f69a355ecf34d5abc08422f7253f3e033 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 02:54:49 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- packages/router-core/src/load-matches.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index f20dbf7d531..ef311d5601b 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -703,7 +703,8 @@ const runLoader = async ( let error = e if ((error as any)?.name === 'AbortError') { - const wasAbortedByNavigation = match.abortController?.signal.aborted === true + const wasAbortedByNavigation = + match.abortController?.signal.aborted === true if (!wasAbortedByNavigation) { inner.updateMatch(matchId, (prev) => ({ From b08ca1ed532098b87d98f8c8206198f9351a009f Mon Sep 17 00:00:00 2001 From: leesb971204 Date: Tue, 20 Jan 2026 13:34:16 +0900 Subject: [PATCH 3/3] test: update expected store update count for preloaded async loaders Signed-off-by: leesb971204 --- .../react-router/tests/store-updates-during-navigation.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 0f189f235b2..9d919ec8899 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -241,7 +241,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(7) + expect(updates).toBe(8) }) test('navigate, w/ preloaded & sync loaders', async () => {