diff --git a/.changeset/ancestor-error-boundary-chunk.md b/.changeset/ancestor-error-boundary-chunk.md new file mode 100644 index 00000000000..57326680570 --- /dev/null +++ b/.changeset/ancestor-error-boundary-chunk.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Load the ancestor `errorComponent` chunk that renders a child loader error before `router.load()` resolves. diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index e84bb8fba59..71d6ec1258a 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -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, + index: number, +): Promise | 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, @@ -1233,12 +1258,18 @@ async function reduceLane( try { await waitFor( 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'), diff --git a/packages/router-core/tests/boundary-component-chunk.test.ts b/packages/router-core/tests/boundary-component-chunk.test.ts index e1302003d27..67be9d55f6f 100644 --- a/packages/router-core/tests/boundary-component-chunk.test.ts +++ b/packages/router-core/tests/boundary-component-chunk.test.ts @@ -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() + 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() const componentError = new Error('hidden component chunk failed')