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
28 changes: 27 additions & 1 deletion packages/react-router/tests/loaders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ test('reproducer #4546', async () => {
component: () => {
return (
<>
<div className="p-2 flex gap-2 text-lg">
<div className="flex gap-2 p-2 text-lg">
<Link
data-testid="link-to-index"
to="/"
Expand Down Expand Up @@ -729,3 +729,29 @@ test('clears pendingTimeout when match resolves', async () => {
expect(nestedPendingComponentOnMountMock).not.toHaveBeenCalled()
expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled()
})

test('throw abortError from loader upon initial load with basepath', async () => {
const rootRoute = createRootRoute({})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
loader: async () => {
return Promise.reject(new DOMException('Aborted', 'AbortError'))
},
component: () => <div>Index route content</div>,
errorComponent: () => (
<div data-testid="index-error">indexErrorComponent</div>
),
})

const routeTree = rootRoute.addChildren([indexRoute])
const router = createRouter({ routeTree, history, basepath: '/app' })

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

const indexElement = await screen.findByText('Index route content')
expect(indexElement).toBeInTheDocument()
expect(screen.queryByTestId('index-error')).not.toBeInTheDocument()
expect(window.location.pathname.startsWith('/app')).toBe(true)
})
10 changes: 10 additions & 0 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,16 @@ const runLoader = async (
} catch (e) {
let error = e

if (error instanceof DOMException && error.name === 'AbortError') {
const head = await executeHead(inner, matchId, route)
inner.updateMatch(matchId, (prev) => ({
...prev,
status: prev.status === 'pending' ? 'success' : prev.status,
...head,
}))
return
}

Comment on lines +673 to +682
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

DOMException check can crash in SSR; also ensure pending-min duration and reset isFetching on async path

  • error instanceof DOMException will throw if DOMException is undefined (some SSR/envs).
  • Early return skips awaiting minPendingPromise, potentially violating pendingMinMs guarantees.
  • In background reloads (loaderShouldRunAsync), isFetching can remain 'loader' after abort since only runLoader can flip it to false.

Fix by detecting abort via name === 'AbortError', awaiting min-pending, and explicitly setting isFetching: false.

-      if (error instanceof DOMException && error.name === 'AbortError') {
-        const head = await executeHead(inner, matchId, route)
-        inner.updateMatch(matchId, (prev) => ({
-          ...prev,
-          status: prev.status === 'pending' ? 'success' : prev.status,
-          ...head,
-        }))
-        return
-      }
+      // Treat aborted loaders as cancellations, not errors
+      if ((error as any)?.name === 'AbortError') {
+        const head = await executeHead(inner, matchId, route)
+        const pendingPromise = match._nonReactive.minPendingPromise
+        if (pendingPromise) await pendingPromise
+        inner.updateMatch(matchId, (prev) => ({
+          ...prev,
+          status: prev.status === 'pending' ? 'success' : prev.status,
+          isFetching: false,
+          ...head,
+        }))
+        return
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (error instanceof DOMException && error.name === 'AbortError') {
const head = await executeHead(inner, matchId, route)
inner.updateMatch(matchId, (prev) => ({
...prev,
status: prev.status === 'pending' ? 'success' : prev.status,
...head,
}))
return
}
// Treat aborted loaders as cancellations, not errors
if ((error as any)?.name === 'AbortError') {
const head = await executeHead(inner, matchId, route)
const pendingPromise = match._nonReactive.minPendingPromise
if (pendingPromise) await pendingPromise
inner.updateMatch(matchId, (prev) => ({
...prev,
status: prev.status === 'pending' ? 'success' : prev.status,
isFetching: false,
...head,
}))
return
}
🤖 Prompt for AI Agents
In packages/router-core/src/load-matches.ts around lines 673–682, replace the
fragile `error instanceof DOMException && error.name === 'AbortError'` check
with a safe abort detection using `error?.name === 'AbortError'` (or equivalent
null-safe check) so SSR environments without DOMException don't throw; before
returning, await the existing min-pending promise used elsewhere (e.g., `await
minPendingPromise`) to honor pendingMinMs guarantees; and call
`inner.updateMatch(matchId, ...)` to explicitly set `isFetching: false` (and
keep the current status transition logic: set status to 'success' only if
previously 'pending') so background loader aborts don't leave `isFetching` stuck
as 'loader'.

const pendingPromise = match._nonReactive.minPendingPromise
if (pendingPromise) await pendingPromise

Expand Down
26 changes: 26 additions & 0 deletions packages/solid-router/tests/loaders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,29 @@ test('throw error from beforeLoad when navigating to route', async () => {
const indexElement = await screen.findByText('fooErrorComponent')
expect(indexElement).toBeInTheDocument()
})

test('throw abortError from loader upon initial load with basepath', async () => {
const rootRoute = createRootRoute({})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
loader: async () => {
return Promise.reject(new DOMException('Aborted', 'AbortError'))
},
component: () => <div>Index route content</div>,
errorComponent: () => (
<div data-testid="index-error">indexErrorComponent</div>
),
})

const routeTree = rootRoute.addChildren([indexRoute])
const router = createRouter({ routeTree, basepath: '/app' })

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

const indexElement = await screen.findByText('Index route content')
expect(indexElement).toBeInTheDocument()
expect(screen.queryByTestId('index-error')).not.toBeInTheDocument()
expect(window.location.pathname.startsWith('/app')).toBe(true)
})
Loading