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/ancestor-error-boundary-chunk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Load the ancestor `errorComponent` chunk that renders a child loader error before `router.load()` resolves.
43 changes: 37 additions & 6 deletions packages/router-core/src/load-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,31 @@ function materializeRedirect(
return outcome
}

/**
* An errored match without its own error component renders through the
* nearest ancestor error boundary, so that boundary's chunk must be ready
* before the lane commits.
*/
function loadAncestorErrorComponent(
router: AnyRouter,
matches: Array<WorkMatch>,
index: number,
): Promise<void> | undefined {
const hasErrorComponent = (route: AnyRoute) =>
route.options.errorComponent ??
(router.options as any).defaultErrorComponent
if (hasErrorComponent(getRoute(router, matches[index]!))) {
return undefined
}
for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
const route = getRoute(router, matches[ancestor]!)
if (hasErrorComponent(route)) {
return loadRouteChunk(route, 'errorComponent')
}
}
return undefined
}

async function reduceLane(
router: AnyRouter,
lane: ContextualizedLane,
Expand Down Expand Up @@ -1233,12 +1258,18 @@ async function reduceLane(
try {
await waitFor<unknown>(
outcome
? Promise.resolve().then(() =>
loadRouteChunk(
route,
kind === ERROR ? 'errorComponent' : 'notFoundComponent',
),
)
? Promise.resolve()
.then(() =>
loadRouteChunk(
route,
kind === ERROR ? 'errorComponent' : 'notFoundComponent',
),
)
.then(() =>
kind === ERROR
? loadAncestorErrorComponent(router, matches, boundary)
: undefined,
)
: Promise.all([
loadRouteChunk(route),
loadRouteChunk(route, 'notFoundComponent'),
Expand Down
77 changes: 77 additions & 0 deletions packages/router-core/tests/boundary-component-chunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,83 @@ describe('route boundary component preloads', () => {
expect(router.state.matches[1]?.error).toBe(routeError)
})

test('a child loader error waits for the ancestor errorComponent chunk that renders it', async () => {
const errorComponentGate = createControlledPromise<void>()
const routeError = new Error('child loader failed')
const errorComponentPreload = vi.fn(() => errorComponentGate)
pendingGates.push(errorComponentGate)

const ErrorBoundary = Object.assign(() => null, {
preload: errorComponentPreload,
})

const rootRoute = new BaseRootRoute({})
const parentRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/parent',
errorComponent: ErrorBoundary as any,
})
const childRoute = new BaseRoute({
getParentRoute: () => parentRoute,
path: '/child',
loader: () => {
throw routeError
},
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
})

let settled = false
const loading = router.load().then(() => {
settled = true
})
pendingLoads.push(loading)

await vi.waitFor(() => expect(errorComponentPreload).toHaveBeenCalledOnce())
await new Promise((resolve) => setTimeout(resolve, 0))
expect(settled).toBe(false)

errorComponentGate.resolve()
await loading

expect(
router.state.matches.find((match) => match.routeId === childRoute.id),
).toMatchObject({ status: 'error', error: routeError })
})

test('a child loader error skips ancestor chunks when the child has a default error component', async () => {
const errorComponentPreload = vi.fn(() => Promise.resolve())
const ErrorBoundary = Object.assign(() => null, {
preload: errorComponentPreload,
})

const rootRoute = new BaseRootRoute({})
const parentRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/parent',
errorComponent: ErrorBoundary as any,
})
const childRoute = new BaseRoute({
getParentRoute: () => parentRoute,
path: '/child',
loader: () => {
throw new Error('child loader failed')
},
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
// Framework adapters declare this option on RouterOptions.
...({ defaultErrorComponent: () => null } as {}),
})

await router.load()

expect(errorComponentPreload).not.toHaveBeenCalled()
})

test('global notFound does not wait for component chunks below its boundary', async () => {
const hiddenComponentGate = createControlledPromise<void>()
const componentError = new Error('hidden component chunk failed')
Expand Down