Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-aborted-loader-undefined-loaderdata.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 30 additions & 3 deletions packages/react-router/tests/loaders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -751,12 +751,39 @@ test('throw abortError from loader upon initial load with basepath', async () =>

render(<RouterProvider router={router} />)

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 <div data-testid="index-content">value: {data.value}</div>
},
errorComponent: () => (
<div data-testid="index-error">indexErrorComponent</div>
),
})

const routeTree = rootRoute.addChildren([indexRoute])
const router = createRouter({ routeTree, history })

render(<RouterProvider router={router} />)

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 = () => {
Expand Down
20 changes: 13 additions & 7 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 30 additions & 3 deletions packages/solid-router/tests/loaders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,39 @@ test('throw abortError from loader upon initial load with basepath', async () =>

render(() => <RouterProvider router={router} />)

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 <div data-testid="index-content">value: {data().value}</div>
},
errorComponent: () => (
<div data-testid="index-error">indexErrorComponent</div>
),
})

const routeTree = rootRoute.addChildren([indexRoute])
const router = createRouter({ routeTree })

render(() => <RouterProvider router={router} />)

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({})
Expand Down