From 8ec67d4435b627bb0e292e425607f95fd0fdd62f Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Mon, 22 Jun 2026 11:05:57 -0400 Subject: [PATCH] fix(router-core): don't resolve aborted match to success without loaderData When a loader is aborted before producing data, the match was promoted from `pending` to `success` without setting `loaderData`. The route component then rendered while `useLoaderData()` returned `undefined`, violating its non-undefined type. Resolve such a match to `error` instead so the errorComponent renders. Stale-while-revalidate (aborts on an already-loaded match) and the rapid-navigation abort path are preserved. --- ...fix-aborted-loader-undefined-loaderdata.md | 5 +++ packages/react-router/tests/loaders.test.tsx | 33 +++++++++++++++++-- packages/router-core/src/load-matches.ts | 20 +++++++---- packages/solid-router/tests/loaders.test.tsx | 33 +++++++++++++++++-- 4 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-aborted-loader-undefined-loaderdata.md diff --git a/.changeset/fix-aborted-loader-undefined-loaderdata.md b/.changeset/fix-aborted-loader-undefined-loaderdata.md new file mode 100644 index 0000000000..8f0e84d777 --- /dev/null +++ b/.changeset/fix-aborted-loader-undefined-loaderdata.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Resolve a match to `error` instead of `success` when its loader is aborted before producing data. Previously an aborted loader could leave the match in `success` with `loaderData` undefined, so the route component rendered while `useLoaderData()` returned `undefined` despite its non-undefined type. diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index d9b5968d72..b727a16064 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -751,12 +751,39 @@ test('throw abortError from loader upon initial load with basepath', async () => render() - const indexElement = await screen.findByText('Index route content') - expect(indexElement).toBeInTheDocument() - expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() + const errorElement = await screen.findByTestId('index-error') + expect(errorElement).toBeInTheDocument() + expect(screen.queryByText('Index route content')).not.toBeInTheDocument() expect(window.location.pathname.startsWith('/app')).toBe(true) }) +test('aborted loader does not render the route component with undefined loaderData', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async (): Promise<{ value: string }> => { + return Promise.reject(new DOMException('Aborted', 'AbortError')) + }, + component: () => { + const data = indexRoute.useLoaderData() + return
value: {data.value}
+ }, + errorComponent: () => ( +
indexErrorComponent
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() +}) + test('cancelMatches after pending timeout', async () => { function getPendingComponent(onMount: () => void) { const PendingComponent = () => { diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index f901a0c97d..cbf37da0d1 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -731,13 +731,19 @@ const runLoader = async ( match._nonReactive.loaderPromise = undefined return } - inner.updateMatch(matchId, (prev) => ({ - ...prev, - status: prev.status === 'pending' ? 'success' : prev.status, - isFetching: false, - context: buildMatchContext(inner, index), - })) - return + // a previous load already produced data, so keep showing it instead of + // surfacing the abort + if (inner.router.getMatch(matchId)?.status !== 'pending') { + inner.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: false, + context: buildMatchContext(inner, index), + })) + return + } + // no loaderData yet: fall through to error handling rather than + // resolving to `success`, which would render the route component with + // loaderData undefined } const pendingPromise = match._nonReactive.minPendingPromise diff --git a/packages/solid-router/tests/loaders.test.tsx b/packages/solid-router/tests/loaders.test.tsx index 063660f592..4ecee0521e 100644 --- a/packages/solid-router/tests/loaders.test.tsx +++ b/packages/solid-router/tests/loaders.test.tsx @@ -342,12 +342,39 @@ test('throw abortError from loader upon initial load with basepath', async () => render(() => ) - const indexElement = await screen.findByText('Index route content') - expect(indexElement).toBeInTheDocument() - expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() + const errorElement = await screen.findByTestId('index-error') + expect(errorElement).toBeInTheDocument() + expect(screen.queryByText('Index route content')).not.toBeInTheDocument() expect(window.location.pathname.startsWith('/app')).toBe(true) }) +test('aborted loader does not render the route component with undefined loaderData', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async (): Promise<{ value: string }> => { + return Promise.reject(new DOMException('Aborted', 'AbortError')) + }, + component: () => { + const data = indexRoute.useLoaderData() + return
value: {data().value}
+ }, + errorComponent: () => ( +
indexErrorComponent
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() +}) + test('reproducer #4245', async () => { const LOADER_WAIT_TIME = 500 const rootRoute = createRootRoute({})