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/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 () => {
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index d9b1d622647..ef311d5601b 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -703,12 +703,19 @@ 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 +780,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