From 23cc7b8f1df43013b50936021ee53217b41f8b26 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 13 Jul 2026 20:38:41 -0700 Subject: [PATCH 1/3] fix(router-core): preserve replacement promises on loader abort --- packages/react-router/tests/loaders.test.tsx | 85 ++++++++++++++++++++ packages/router-core/src/load-matches.ts | 44 ++++++---- 2 files changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index d9b5968d72..a061a04960 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -940,3 +940,88 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul expect(paramPage).toHaveTextContent('Param Component 1 Done') expect(loaderCompleteMock).toHaveBeenCalled() }) + +test('aborting a reused parent match does not clear the replacement load promise', async () => { + const rootAbortMock = vi.fn() + const indexAbortMock = vi.fn() + const errorComponentMock = vi.fn() + + const abortableDelay = ( + abortController: AbortController, + onAbort: () => void, + ) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, WAIT_TIME) + abortController.signal.addEventListener('abort', () => { + clearTimeout(timer) + onAbort() + reject( + new DOMException('signal is aborted without reason', 'AbortError'), + ) + }) + }) + + const rootRoute = createRootRoute({ + loader: async ({ abortController }) => { + await abortableDelay(abortController, rootAbortMock) + return 'root loaded' + }, + component: Outlet, + errorComponent: ({ error }) => { + errorComponentMock(error) + return
{error.message}
+ }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (search): { filter: string } => ({ + filter: typeof search.filter === 'string' ? search.filter : '', + }), + loaderDeps: ({ search }) => ({ filter: search.filter }), + loader: async ({ deps, abortController }) => { + await abortableDelay(abortController, indexAbortMock) + return deps.filter + }, + component: () => { + const search = indexRoute.useSearch() + const navigate = indexRoute.useNavigate() + return ( +
+ { + void navigate({ + to: '/', + search: { filter: event.target.value }, + }) + }} + /> +
+ ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + await act(() => router.latestLoadPromise) + + const input = await screen.findByTestId('filter-input') + for (const filter of ['a', 'ab', 'abc', 'abcd', 'abcde', 'abcdef']) { + fireEvent.change(input, { target: { value: filter } }) + } + await act(() => router.latestLoadPromise) + + expect(rootAbortMock).toHaveBeenCalled() + expect(indexAbortMock).toHaveBeenCalled() + expect(errorComponentMock).not.toHaveBeenCalled() + expect(screen.queryByTestId('route-error')).not.toBeInTheDocument() + expect(await screen.findByTestId('index-page')).toBeInTheDocument() + expect(router.state.location.search).toEqual({ filter: 'abcdef' }) +}) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index f901a0c97d..8a05730e95 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -14,6 +14,7 @@ import type { } from './route' import type { AnyRouteMatch, MakeRouteMatch } from './Matches' import type { AnyRouter, SSROption, UpdateMatchFn } from './router' +import type { ControlledPromise } from './utils' /** * An object of this shape is created when calling `loadMatches`. @@ -35,6 +36,7 @@ type InnerLoadContext = { forceStaleReload?: boolean onReady?: () => Promise sync?: boolean + loadPromises: Array | undefined> } const triggerOnReady = (inner: InnerLoadContext): void | Promise => { @@ -395,10 +397,12 @@ const executeBeforeLoad = ( // explicitly capture the previous loadPromise let prevLoadPromise = match._nonReactive.loadPromise - match._nonReactive.loadPromise = createControlledPromise(() => { + const loadPromise = createControlledPromise(() => { prevLoadPromise?.resolve() prevLoadPromise = undefined }) + match._nonReactive.loadPromise = loadPromise + inner.loadPromises[index] = loadPromise const { paramsError, searchError } = match @@ -727,8 +731,7 @@ const runLoader = async ( if ((error as any)?.name === 'AbortError') { if (match.abortController.signal.aborted) { - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loaderPromise = undefined + // loadRouteMatch resolves the promises owned by this execution. return } inner.updateMatch(matchId, (prev) => ({ @@ -835,11 +838,13 @@ const loadRouteMatch = async ( ;(async () => { try { await runLoader(inner, matchPromises, matchId, index, route) - const match = inner.router.getMatch(matchId)! - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loaderPromise = undefined - match._nonReactive.loadPromise = undefined + loaderPromise?.resolve() + loadPromise?.resolve() + const nonReactive = inner.router.getMatch(matchId)?._nonReactive + if (nonReactive && nonReactive.loadPromise === loadPromise) { + nonReactive.loaderPromise = undefined + nonReactive.loadPromise = undefined + } } catch (err) { if (isRedirect(err)) { await inner.router.navigate(err.options) @@ -854,6 +859,8 @@ const loadRouteMatch = async ( } const { id: matchId, routeId } = inner.matches[index]! + const loadPromise = inner.loadPromises[index] + let loaderPromise: ControlledPromise | undefined let loaderShouldRunAsync = false let loaderIsRunningAsync = false const route = inner.router.looseRoutesById[routeId]! @@ -910,6 +917,8 @@ const loadRouteMatch = async ( } if (match.status === 'pending') { + loaderPromise = createControlledPromise() + match._nonReactive.loaderPromise = loaderPromise await handleLoader( preload, prevMatch, @@ -922,7 +931,8 @@ const loadRouteMatch = async ( const nextPreload = preload && !inner.router.stores.matchStores.has(matchId) const match = inner.router.getMatch(matchId)! - match._nonReactive.loaderPromise = createControlledPromise() + loaderPromise = createControlledPromise() + match._nonReactive.loaderPromise = loaderPromise if (nextPreload !== match.preload) { inner.updateMatch(matchId, (prev) => ({ ...prev, @@ -935,14 +945,20 @@ const loadRouteMatch = async ( } const match = inner.router.getMatch(matchId)! if (!loaderIsRunningAsync) { - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loadPromise = undefined + loaderPromise?.resolve() + loadPromise?.resolve() } + if (match._nonReactive.loadPromise !== loadPromise) { + return match + } + + if (!loaderIsRunningAsync) { + match._nonReactive.loadPromise = undefined + match._nonReactive.loaderPromise = undefined + } clearTimeout(match._nonReactive.pendingTimeout) match._nonReactive.pendingTimeout = undefined - if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined match._nonReactive.dehydrated = undefined const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false @@ -968,7 +984,7 @@ export async function loadMatches(arg: { updateMatch: UpdateMatchFn sync?: boolean }): Promise> { - const inner: InnerLoadContext = arg + const inner: InnerLoadContext = { ...arg, loadPromises: [] } const matchPromises: Array> = [] // make sure the pending component is immediately rendered when hydrating a match that is not SSRed From 297f08375011902f851470d72035428b41099269 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 13 Jul 2026 20:47:43 -0700 Subject: [PATCH 2/3] changeset --- .changeset/eight-feet-lose.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eight-feet-lose.md diff --git a/.changeset/eight-feet-lose.md b/.changeset/eight-feet-lose.md new file mode 100644 index 0000000000..7011b7973f --- /dev/null +++ b/.changeset/eight-feet-lose.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Fixed aborted loader executions clearing promises owned by a newer load of the same route match. From ebca5c7d15293469881f8fe4bd3ef1c5dbce4144 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 13 Jul 2026 21:11:16 -0700 Subject: [PATCH 3/3] strengthen test --- packages/react-router/tests/loaders.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index a061a04960..b82933521d 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -943,6 +943,7 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul test('aborting a reused parent match does not clear the replacement load promise', async () => { const rootAbortMock = vi.fn() + const rootLoaderMock = vi.fn() const indexAbortMock = vi.fn() const errorComponentMock = vi.fn() @@ -963,9 +964,11 @@ test('aborting a reused parent match does not clear the replacement load promise const rootRoute = createRootRoute({ loader: async ({ abortController }) => { + rootLoaderMock() await abortableDelay(abortController, rootAbortMock) return 'root loaded' }, + shouldReload: true, component: Outlet, errorComponent: ({ error }) => { errorComponentMock(error) @@ -985,10 +988,12 @@ test('aborting a reused parent match does not clear the replacement load promise return deps.filter }, component: () => { + const data = indexRoute.useLoaderData() const search = indexRoute.useSearch() const navigate = indexRoute.useNavigate() return (
+
{data}
router.latestLoadPromise) + expect(rootLoaderMock.mock.calls.length).toBeGreaterThan(1) expect(rootAbortMock).toHaveBeenCalled() expect(indexAbortMock).toHaveBeenCalled() expect(errorComponentMock).not.toHaveBeenCalled() expect(screen.queryByTestId('route-error')).not.toBeInTheDocument() expect(await screen.findByTestId('index-page')).toBeInTheDocument() + expect(await screen.findByTestId('index-loader-data')).toHaveTextContent( + 'abcdef', + ) expect(router.state.location.search).toEqual({ filter: 'abcdef' }) })