diff --git a/e2e/react-start/basic/rsbuild.config.ts b/e2e/react-start/basic/rsbuild.config.ts index e6ad170a60..23072b6c1b 100644 --- a/e2e/react-start/basic/rsbuild.config.ts +++ b/e2e/react-start/basic/rsbuild.config.ts @@ -7,10 +7,7 @@ const outDir = process.env.E2E_DIST_DIR ?? 'dist' const startModeConfig = getStartModeConfig() export default defineConfig({ - plugins: [ - pluginReact(), - tanstackStart(startModeConfig), - ], + plugins: [pluginReact(), tanstackStart(startModeConfig)], output: { distPath: { root: outDir, diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 2d89949001..f338aa6611 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1231,25 +1231,41 @@ export async function projectLane( end = lane[1 /* matches */].length, ): Promise { const matches = lane[1 /* matches */] + let projections: Array<[WorkMatch, Promise]> | undefined for (let index = start; index < end; index++) { const match = matches[index]! const routeOptions = getRoute(router, match).options if (routeOptions.head || routeOptions.scripts) { + const context = { + ssr: router.options.ssr, + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + let projection try { - const context = { - ssr: router.options.ssr, - matches, - match, - params: match.params, - loaderData: match.loaderData, - } - const [head, scripts] = await waitFor( + projection = waitFor( Promise.all([ routeOptions.head?.(context), routeOptions.scripts?.(context), ]), signal, ) + } catch (cause) { + projection = Promise.reject(cause) + } + void projection.catch(() => {}) + ;(projections ??= []).push([match, projection]) + } + if (match.status !== 'success' || match._notFound) { + break + } + } + if (projections) { + for (const [match, projection] of projections) { + try { + const [head, scripts] = await projection match.meta = head?.meta match.links = head?.links match.headScripts = head?.scripts @@ -1262,9 +1278,6 @@ export async function projectLane( console.error(cause) } } - if (match.status !== 'success' || match._notFound) { - break - } } return lane as ProjectedLane } diff --git a/packages/router-core/src/load-server.ts b/packages/router-core/src/load-server.ts index 7608b0fc65..4099f43d39 100644 --- a/packages/router-core/src/load-server.ts +++ b/packages/router-core/src/load-server.ts @@ -620,6 +620,7 @@ async function projectLane( lane: ReducedLane, signal?: AbortSignal, ): Promise { + let projections: Array<[AnyRouteMatch, Promise]> | undefined for (const match of lane.matches) { const routeOptions = getRoute(router, match).options if (routeOptions.head || routeOptions.scripts || routeOptions.headers) { @@ -630,12 +631,27 @@ async function projectLane( params: match.params, loaderData: match.loaderData, } + let projection try { - const [head, scripts, headers] = await Promise.all([ + projection = Promise.all([ routeOptions.head?.(context), routeOptions.scripts?.(context), routeOptions.headers?.(context), ]) + } catch (cause) { + projection = Promise.reject(cause) + } + void projection.catch(() => {}) + ;(projections ??= []).push([match, projection]) + } + if (match.ssr === false || match.status !== 'success' || match._notFound) { + break + } + } + if (projections) { + for (const [match, projection] of projections) { + try { + const [head, scripts, headers] = await projection signal?.throwIfAborted() match.meta = head?.meta match.links = head?.links @@ -648,9 +664,6 @@ async function projectLane( console.error(cause) } } - if (match.ssr === false || match.status !== 'success' || match._notFound) { - break - } } } diff --git a/packages/router-core/tests/route-assets-parallel.test.ts b/packages/router-core/tests/route-assets-parallel.test.ts new file mode 100644 index 0000000000..b490c234f7 --- /dev/null +++ b/packages/router-core/tests/route-assets-parallel.test.ts @@ -0,0 +1,66 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +describe.each([false, true])( + 'route asset projection (server: %s)', + (isServer) => { + test('starts child assets before parent assets settle', async () => { + const parentStarted = createControlledPromise() + const parentHead = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const parentScripts = + createControlledPromise>() + const childHead = vi.fn(() => ({ + meta: [{ title: 'child' }], + })) + const childScripts = vi.fn(() => [{ children: 'window.child = true' }]) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + head: () => { + parentStarted.resolve() + return parentHead + }, + scripts: () => parentScripts, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, + scripts: childScripts, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer, + }) + + const loading = isServer + ? loadServerResponse(router, '/parent/child') + : router.load() + try { + await parentStarted + + expect(parentScripts.status).toBe('pending') + expect(childHead).toHaveBeenCalledTimes(1) + expect(childScripts).toHaveBeenCalledTimes(1) + } finally { + parentHead.resolve({ meta: [{ title: 'parent' }] }) + parentScripts.resolve([{ children: 'window.parent = true' }]) + await loading + } + + expect(router.state.matches.at(-1)).toMatchObject({ + meta: [{ title: 'child' }], + scripts: [{ children: 'window.child = true' }], + }) + }) + }, +)