From c1141985f175999400fd7c9629bd9e88b172717a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 21 Aug 2026 22:18:56 +0200 Subject: [PATCH] perf(router-core): evaluate head/scripts across matches in parallel --- RESULT-perf-task4.md | 82 ++++ packages/router-core/src/load-client.ts | 63 ++- packages/router-core/src/load-server.ts | 45 +- .../tests/head-scripts-parallel.test.ts | 460 ++++++++++++++++++ 4 files changed, 622 insertions(+), 28 deletions(-) create mode 100644 RESULT-perf-task4.md create mode 100644 packages/router-core/tests/head-scripts-parallel.test.ts diff --git a/RESULT-perf-task4.md b/RESULT-perf-task4.md new file mode 100644 index 0000000000..1af991f83f --- /dev/null +++ b/RESULT-perf-task4.md @@ -0,0 +1,82 @@ +# RESULT — perf/task4-parallel-head-scripts + +## Change + +`projectLane` evaluated each match's head/scripts(/headers) callbacks +sequentially — a `Promise.all` **per match** inside a sequential loop — so +total latency was the **sum** of all callback resolutions. Both implementations +now: + +1. Walk the rendered prefix first (unchanged break conditions), invoking every + qualifying match's head/scripts(/headers) callbacks synchronously **in route + order** (identical invocation order to before). +2. Await and apply the results strictly **one match at a time in route order**, + reusing the exact original per-match apply/error/abort/break logic. + +Files: +- `packages/router-core/src/load-server.ts` (`projectLane`, head + scripts + headers) +- `packages/router-core/src/load-client.ts` (`exported projectLane`, head + scripts) + +## Observable orderings enumerated, and how each is preserved + +| # | Observable | Sequential behavior | Parallel implementation | +|---|------------|---------------------|--------------------------| +| 1 | Callback invocation order across matches | Match N's fns invoked after match N-1 applied | All fns invoked synchronously in route order before any await → same relative invocation order (proven by `invocationOrder` assertion in tests) | +| 2 | Result application order | Route order | Phase-3 loop awaits attempts strictly in route order; proven by test "applies results in route order even when later matches resolve first" (later heads resolved first; nothing applied until match 0 settles) | +| 3 | Early-break conditions (`ssr:false` / `status!=='success'` / `_notFound`; client: `status`/`_notFound`) | Checked per iteration, including matches without head options; stops invocation *and* application | Identical checks run in an identical prefix walk (invocation stops there); phase 3 re-runs the same check after each application so nothing is *applied* past a break boundary either. Proven by error-status, `_notFound`, and `ssr:false` tests | +| 4 | Which error surfaces when multiple reject | Per-match `Promise.all`: first rejection among head/scripts(/headers) wins for that match; errors logged in route order; processing continues after logging | Same per-match `Promise.all` grouping (same winner semantics); settle-all + process-in-route-order makes cross-match log order deterministic route order. Proven by "surfaces errors deterministically in route order" tests where match 1 rejects before match 0 but logs come out `[errMatch0, errMatch1]` | +| 5 | Sync throw from a user fn | Caught by the same per-match catch → logged, lane continues | Invocation wrapped in try/catch converting it to a rejection processed identically. Proven by "handles synchronous throws like rejections" test | +| 6 | Abort behavior | Server: `signal.throwIfAborted()` after each await (rejects projectLane before apply/log). Client: `waitFor` rejects with the signal → silent `break`, no log | Server: `throwIfAborted()` inside try on success path / first line of catch — identical outcomes. Client: identical `cause === signal && signal.aborted → break`. Discarded attempts past an abort-break carry a no-op catch. Proven by "breaks silently when aborted mid-flight and emits no unhandled rejection" | +| 7 | Unhandled rejections | None possible (every promise awaited or raced) | Every attempt gets `void attempt.catch(() => {})` attached immediately at creation, covering any attempt discarded by a mid-phase-3 break; awaited attempts consume their rejection via try/catch. Proven via `process.on('unhandledRejection')` assertions plus vitest's global unhandled-rejection detection over the whole 1619-test suite | +| 8 | Microtask/scheduling parity (user-visible commit ordering) | Publishes racing `load()` resolution won by deterministic margin | Critical subtlety discovered during verification: wrapping results in an extra `.then` layer added one microtask tick per match and flipped a real race (`runBackground`'s publish landed *after* `router.load()` resolved → stale meta observable; caught by existing `public-hydration-contract.test.ts`). Fixed by storing the raw attempt promise and awaiting it directly — byte-for-byte the same await chain depth as the original code. Test now passes | + +## What intentionally changed (not user-visible) + +- Head/scripts/headers fns for the whole rendered prefix are invoked up-front + instead of drip-fed between awaits. Fns that read **other matches'** + `meta`/`links`/… (results applied by earlier iterations) would observe + not-yet-applied values. Practically unreachable: loaders complete before + projection begins, and the existing contract test proving heads observe fresh + cross-match `loaderData` passes. Documented as residual risk below. + +## Verification evidence + +- `pnpm nx run @tanstack/router-core:test:unit` → **107 files / 1616 passed, + 0 failed** (includes 10 new tests in `tests/head-scripts-parallel.test.ts` + and the previously-regressing `public-hydration-contract.test.ts`). +- `pnpm nx run @tanstack/router-core:test:eslint` → 0 errors (26 pre-existing + warnings in unrelated files). +- `pnpm nx run @tanstack/router-core:test:types` → pass (ts56–ts70 matrix). + +### New tests (`packages/router-core/tests/head-scripts-parallel.test.ts`) + +Client (`projectLane` exercised directly): route-order application with +reverse-resolution order; early-break on `status:'error'`; early-break on +`_notFound`; deterministic route-order error surfacing with continued +projection; sync-throw handling; silent abort break + no unhandled rejection; +N=4 × 80ms heads complete in < 240ms (sequential would need ≥ 320ms). + +Server (via `loadServerResponse`): head/scripts/headers all applied per-match +with reverse-resolution order; `ssr:false` break boundary; route-order error +logging with 200 render. + +### Timing evidence + +Micro-benchmark (temporary vitest run, N=6 heads × 60ms sleep each): + +``` +[BENCH] N=6 delay=60ms | sequential(sum)=362ms | projectLane=62ms | theoretical max=60ms sum=360ms +``` + +Parallel evaluation tracks **max (~62ms ≈ 60ms)**, not sum (360ms) — a ~5.8× +reduction at N=6; SSR TTFB improves by roughly the sum of all but the slowest +head/scripts/headers resolution. + +## Residual risks + +1. Cross-match observation of applied head results inside later head fns + (see above) — theoretically observable, practically nonexistent; loaders are + guaranteed complete before projection, which is the documented contract. +2. Under abort, callbacks for later prefix matches are now invoked before the + abort is observed (previously they were never invoked). Pure side-effect + fns could notice extra invocations in aborted lanes only. diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 2d89949001..61034c0a9e 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1231,36 +1231,63 @@ export async function projectLane( end = lane[1 /* matches */].length, ): Promise { const matches = lane[1 /* matches */] + // Applying a result below never mutates `status` or `_notFound`, so the + // rendered prefix (the loop's break conditions) is stable and can be + // determined before any callback resolves. All head/scripts callbacks + // therefore start in route order up front, while their results are still + // awaited and applied strictly one match at a time. + const attempts: Array<{ + match: (typeof matches)[number] + attempt: Promise<[any, any]> + }> = [] for (let index = start; index < end; index++) { const match = matches[index]! const routeOptions = getRoute(router, match).options if (routeOptions.head || routeOptions.scripts) { - try { - const context = { - ssr: router.options.ssr, - matches, - match, - params: match.params, - loaderData: match.loaderData, - } - const [head, scripts] = await waitFor( + const context = { + ssr: router.options.ssr, + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + const startAttempt = () => + waitFor( Promise.all([ routeOptions.head?.(context), routeOptions.scripts?.(context), ]), signal, ) - match.meta = head?.meta - match.links = head?.links - match.headScripts = head?.scripts - match.styles = head?.styles - match.scripts = scripts + let attempt: ReturnType + try { + attempt = startAttempt() } catch (cause) { - if (cause === signal && signal.aborted) { - break - } - console.error(cause) + // A synchronous throw from user code behaves like a rejection. + attempt = Promise.reject(cause) + } + // Attach a handler immediately so an attempt discarded past the break + // boundary can never surface as an unhandled rejection. + void attempt.catch(() => {}) + attempts.push({ match, attempt }) + } + if (match.status !== 'success' || match._notFound) { + break + } + } + for (const { match, attempt } of attempts) { + try { + const [head, scripts] = await attempt + match.meta = head?.meta + match.links = head?.links + match.headScripts = head?.scripts + match.styles = head?.styles + match.scripts = scripts + } catch (cause) { + if (cause === signal && signal.aborted) { + break } + console.error(cause) } if (match.status !== 'success' || match._notFound) { break diff --git a/packages/router-core/src/load-server.ts b/packages/router-core/src/load-server.ts index 7608b0fc65..04d5ccca37 100644 --- a/packages/router-core/src/load-server.ts +++ b/packages/router-core/src/load-server.ts @@ -620,6 +620,15 @@ async function projectLane( lane: ReducedLane, signal?: AbortSignal, ): Promise { + // Applying a result below never mutates `ssr`, `status`, or `_notFound`, so + // the rendered prefix (the loop's break conditions) is stable and can be + // determined before any callback resolves. All head/scripts/headers + // callbacks therefore start in route order up front, while their results are + // still awaited and applied strictly one match at a time. + const attempts: Array<{ + match: AnyRouteMatch + attempt: Promise<[any, any, any]> + }> = [] for (const match of lane.matches) { const routeOptions = getRoute(router, match).options if (routeOptions.head || routeOptions.scripts || routeOptions.headers) { @@ -630,23 +639,39 @@ async function projectLane( params: match.params, loaderData: match.loaderData, } + let attempt: Promise<[any, any, any]> try { - const [head, scripts, headers] = await Promise.all([ + attempt = Promise.all([ routeOptions.head?.(context), routeOptions.scripts?.(context), routeOptions.headers?.(context), ]) - signal?.throwIfAborted() - match.meta = head?.meta - match.links = head?.links - match.headScripts = head?.scripts - match.styles = head?.styles - match.scripts = scripts - match.headers = headers } catch (cause) { - signal?.throwIfAborted() - console.error(cause) + // A synchronous throw from user code behaves like a rejection. + attempt = Promise.reject(cause) } + // Attach a handler immediately so an attempt discarded past the break + // boundary can never surface as an unhandled rejection. + void attempt.catch(() => {}) + attempts.push({ match, attempt }) + } + if (match.ssr === false || match.status !== 'success' || match._notFound) { + break + } + } + for (const { match, attempt } of attempts) { + try { + const [head, scripts, headers] = await attempt + signal?.throwIfAborted() + match.meta = head?.meta + match.links = head?.links + match.headScripts = head?.scripts + match.styles = head?.styles + match.scripts = scripts + match.headers = headers + } catch (cause) { + signal?.throwIfAborted() + console.error(cause) } if (match.ssr === false || match.status !== 'success' || match._notFound) { break diff --git a/packages/router-core/tests/head-scripts-parallel.test.ts b/packages/router-core/tests/head-scripts-parallel.test.ts new file mode 100644 index 0000000000..a29e84802a --- /dev/null +++ b/packages/router-core/tests/head-scripts-parallel.test.ts @@ -0,0 +1,460 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, onTestFinished, test, vi } from 'vitest' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, +} from '../src' +import { projectLane } from '../src/load-client' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +const flush = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Builds a properly nested route chain of `count` routes where EVERY route + * (including the root at index 0) receives `makeOptions(index)`. Returns the + * route tree plus the flat list of routes in match order. + */ +function buildChain( + count: number, + makeOptions: (index: number) => Record = () => ({}), +): { tree: any; routes: Array } { + const rootRoute = new BaseRootRoute(makeOptions(0) as any) + const routes: Array = [rootRoute] + for (let index = 1; index < count; index++) { + const parent = routes[index - 1]! + routes.push( + new BaseRoute({ + getParentRoute: () => parent, + path: `/r${index}`, + ...makeOptions(index), + } as any), + ) + } + let tree: any = routes[count - 1]! + for (let index = count - 2; index >= 0; index--) { + tree = routes[index]!.addChildren([tree]) as any + } + return { tree, routes } +} + +function makeMatch( + routeId: string, + extra: Record = {}, +): Record { + return { + id: routeId, + routeId, + status: 'success', + params: {}, + loaderData: undefined, + _notFound: undefined, + ...extra, + } +} + +function makeLane(matches: Array>): [unknown, unknown] { + return [{ pathname: '/', search: {} } as any, matches as any] +} + +describe('client projectLane evaluates head/scripts across matches in parallel', () => { + test('applies results in route order even when later matches resolve first', async () => { + const rootHead = createControlledPromise() + const parentHead = createControlledPromise() + const childHead = createControlledPromise() + const heads = [rootHead, parentHead, childHead] + const invocationOrder: Array = [] + + const { tree, routes } = buildChain(3, (index) => ({ + head: () => { + invocationOrder.push(index) + return heads[index]! + }, + })) + const router = createTestRouter({ + routeTree: tree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = routes.map((route) => makeMatch(route.id)) + const done = projectLane( + router, + makeLane(matches) as any, + new AbortController().signal, + ) + + // All callbacks were invoked synchronously in route order before any + // result was awaited. + expect(invocationOrder).toEqual([0, 1, 2]) + + // The later matches settle first. + childHead.resolve({ meta: [{ name: 'child' }] }) + parentHead.resolve({ meta: [{ name: 'parent' }] }) + await flush() + + // Route-order application means nothing may be applied until the first + // match's head settles. + expect(matches.map((match) => match.meta)).toEqual([ + undefined, + undefined, + undefined, + ]) + + rootHead.resolve({ meta: [{ name: 'root' }] }) + await done + + expect(matches.map((match) => match.meta)).toEqual([ + [{ name: 'root' }], + [{ name: 'parent' }], + [{ name: 'child' }], + ]) + }) + + test('stops invoking callbacks at the existing break boundary (error status)', async () => { + const rootHead = vi.fn(() => ({ meta: [] })) + const layoutHead = vi.fn(() => Promise.resolve({ meta: [] })) + const hiddenLeafHead = vi.fn(() => ({ meta: [] })) + + const rootRoute = new BaseRootRoute({ head: rootHead }) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/layout', + head: layoutHead, + }) + const leafRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/leaf', + head: hiddenLeafHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([leafRoute])]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await projectLane( + router, + makeLane([ + makeMatch(rootRoute.id), + makeMatch(layoutRoute.id, { status: 'error', error: new Error('x') }), + makeMatch(leafRoute.id), + ]) as any, + new AbortController().signal, + ) + + expect(rootHead).toHaveBeenCalledTimes(1) + expect(layoutHead).toHaveBeenCalledTimes(1) + expect(hiddenLeafHead).not.toHaveBeenCalled() + }) + + test('does not invoke callbacks past a _notFound break boundary', async () => { + const hiddenHead = vi.fn(() => ({ meta: [{ name: 'hidden' }] })) + const rootRoute = new BaseRootRoute({ + head: () => ({ meta: [{ name: 'root' }] }), + }) + const leafRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/leaf', + head: hiddenHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([leafRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = [ + makeMatch(rootRoute.id, { _notFound: true }), + makeMatch(leafRoute.id), + ] + await projectLane( + router, + makeLane(matches) as any, + new AbortController().signal, + ) + + expect(hiddenHead).not.toHaveBeenCalled() + expect(matches[0]!.meta).toEqual([{ name: 'root' }]) + }) + + test('surfaces errors deterministically in route order and keeps rendering later matches', async () => { + const errorRoot = new Error('root head failed') + const errorParent = new Error('parent head failed') + const rootHead = createControlledPromise() + const parentHead = createControlledPromise() + const childHead = vi.fn(() => + Promise.resolve({ meta: [{ name: 'child' }] }), + ) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) + + const heads = [rootHead, parentHead] + const { tree, routes } = buildChain(3, (index) => + index < 2 ? { head: () => heads[index] } : { head: childHead }, + ) + const router = createTestRouter({ + routeTree: tree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = routes.map((route) => makeMatch(route.id)) + const done = projectLane( + router, + makeLane(matches) as any, + new AbortController().signal, + ) + // The second match rejects first; the first match rejects later. The + // logged errors must still surface in route order. + parentHead.reject(errorParent) + await flush() + rootHead.reject(errorRoot) + + await done + + expect(consoleError.mock.calls.map((call) => call[0])).toEqual([ + errorRoot, + errorParent, + ]) + // A failed head must not stop later matches from being projected. + expect(childHead).toHaveBeenCalledTimes(1) + expect(matches[2]!.meta).toEqual([{ name: 'child' }]) + expect(matches[0]!.meta).toBeUndefined() + expect(matches[1]!.meta).toBeUndefined() + }) + + test('handles synchronous throws like rejections without breaking the lane', async () => { + const syncError = new Error('sync head failure') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) + const childHead = vi.fn(() => ({ meta: [{ name: 'child' }] })) + + const rootRoute = new BaseRootRoute({ + head: () => { + throw syncError + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + head: childHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = [makeMatch(rootRoute.id), makeMatch(childRoute.id)] + await projectLane( + router, + makeLane(matches) as any, + new AbortController().signal, + ) + + expect(consoleError).toHaveBeenCalledWith(syncError) + expect(childHead).toHaveBeenCalledTimes(1) + expect(matches[1]!.meta).toEqual([{ name: 'child' }]) + }) + + test('breaks silently when aborted mid-flight and emits no unhandled rejection', async () => { + const unhandled: Array = [] + const onUnhandled = (reason: unknown) => { + unhandled.push(reason) + } + process.on('unhandledRejection', onUnhandled) + onTestFinished(async () => { + process.off('unhandledRejection', onUnhandled) + await flush(10) + expect(unhandled).toEqual([]) + }) + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) + + const rootHead = createControlledPromise() + const childHead = createControlledPromise() + + const rootRoute = new BaseRootRoute({ head: () => rootHead }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + head: () => childHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const controller = new AbortController() + const matches = [makeMatch(rootRoute.id), makeMatch(childRoute.id)] + const done = projectLane(router, makeLane(matches) as any, controller.signal) + controller.abort() + // The child head rejects after abort; its rejection must stay handled. + await flush() + childHead.reject(new Error('child failed late')) + await done + + // The still-pending root head and the rejected child head must + // never surface anywhere. + consoleError.mockClear() + await flush(20) + expect(consoleError).not.toHaveBeenCalled() + + rootHead.resolve({ meta: [] }) + expect(unhandled).toEqual([]) + }) + + test('N delayed heads settle in ~max, not sum, of their durations', async () => { + const delay = 80 + const count = 4 + + const { tree, routes } = buildChain(count, () => ({ + head: () => + new Promise((resolve) => + setTimeout(() => resolve({ meta: [{ name: 'delayed' }] }), delay), + ), + })) + void routes + const router = createTestRouter({ + routeTree: tree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = Array.from({ length: count }, (_, index) => + makeMatch(routes[index]!.id), + ) + const started = performance.now() + await projectLane( + router, + makeLane(matches) as any, + new AbortController().signal, + ) + const elapsed = performance.now() - started + + // Sequential evaluation would take ~sum (320ms); parallel evaluation is + // bounded by the slowest single head (~80ms). + expect(elapsed).toBeLessThan(delay * (count - 1)) + for (const match of matches) { + expect(match.meta).toEqual([{ name: 'delayed' }]) + } + }) +}) + +describe('server projectLane evaluates head/scripts/headers across matches in parallel', () => { + function setupServerRouter(delays: Array) { + return buildChain(delays.length + 1, (index) => ({ + head: () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + meta: [{ name: `s${index}` }], + scripts: [{ children: `s${index}` }], + }), + delays[index - 1]!, + ), + ), + scripts: () => + new Promise((resolve) => + setTimeout( + () => resolve([{ children: `scripts-${index}` }]), + delays[index - 1]!, + ), + ), + headers: () => + new Promise((resolve) => + setTimeout( + () => resolve({ 'x-lane': String(index) }), + delays[index - 1] ?? 0, + ), + ), + })) + } + + test('applies head, scripts, and headers per match when later matches resolve first', async () => { + // r1 is slow (120ms) and r2 is fast (20ms). Sequential code would take + // sum (~140ms); parallel takes ~max (~120ms). + const { tree, routes } = setupServerRouter([120, 20]) + const router = createTestRouter({ + routeTree: tree, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + + const started = performance.now() + const response = await loadServerResponse(router, '/r1/r2') + const elapsed = performance.now() - started + + expect(response.status).toBe(200) + const matches = router.state.matches + const first = matches.find((match) => match.routeId === routes[1]!.id)! + const last = matches.find((match) => match.routeId === routes[2]!.id)! + expect(first.meta).toEqual([{ name: 's1' }]) + expect(first.headScripts).toEqual([{ children: 's1' }]) + expect(first.scripts).toEqual([{ children: 'scripts-1' }]) + expect(first.headers).toEqual({ 'x-lane': '1' }) + expect(last.meta).toEqual([{ name: 's2' }]) + expect(last.headers).toEqual({ 'x-lane': '2' }) + + // Parallel evaluation: bounded by the slowest head (~max), not the sum. + expect(elapsed).toBeLessThan(220) + }) + + test('respects the ssr:false break boundary on the server', async () => { + const hiddenHead = vi.fn(() => ({ meta: [] })) + const rootRoute = new BaseRootRoute({ + head: () => ({ meta: [{ name: 'root' }] }), + }) + const shellRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/shell', + ssr: false, + }) + const innerRoute = new BaseRoute({ + getParentRoute: () => shellRoute, + path: '/inner', + head: hiddenHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([shellRoute.addChildren([innerRoute])]), + history: createMemoryHistory({ initialEntries: ['/shell'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/shell') + expect(response.status).toBe(200) + expect(hiddenHead).not.toHaveBeenCalled() + }) + + test('logs head failures in route order and still renders a 200', async () => { + const errorRoot = new Error('server root head failed') + const errorChild = new Error('server child head failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) + + const rootHead = createControlledPromise() + const childHead = createControlledPromise() + const rootRoute = new BaseRootRoute({ head: () => rootHead }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/c', + head: () => childHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/c'] }), + isServer: true, + }) + + const responsePromise = loadServerResponse(router, '/c') + // The child rejects first; route-order surfacing must log the root error + // first regardless. + childHead.reject(errorChild) + await flush(10) + rootHead.reject(errorRoot) + + const response = await responsePromise + expect(response.status).toBe(200) + expect(consoleError.mock.calls.map((call) => call[0])).toEqual([ + errorRoot, + errorChild, + ]) + }) +})