diff --git a/.changeset/curly-dodos-import.md b/.changeset/curly-dodos-import.md new file mode 100644 index 00000000000..6b7b0f2734b --- /dev/null +++ b/.changeset/curly-dodos-import.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +--- + +Pass router SSR import maps through to React's streaming server renderers. diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 178eace31bd..7cecdb52928 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -15,6 +15,14 @@ import type { } from './route' declare module '@tanstack/router-core' { + export interface RouterSsrOptionsExtensions { + importMap?: { + imports?: Record + integrity?: Record + scopes?: Record> + } + } + export interface RouterOptionsExtensions { /** * The default `component` a route should use if no component is provided. diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index abfe36b86de..6eecdcdd82d 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -41,6 +41,12 @@ const isAbortError = (request: Request, error: unknown) => (request.signal.aborted && error === request.signal.reason) || (error instanceof Error && error.name === 'AbortError') +/** + * Renders a React router application to a streaming SSR response. + * + * @param options - The request, router, response headers, and React content to render. + * @returns The router-managed streaming response. + */ export const renderRouterToStream = async ({ request, router, @@ -55,6 +61,7 @@ export const renderRouterToStream = async ({ if (typeof ReactDOMServer.renderToReadableStream === 'function') { const stream = await ReactDOMServer.renderToReadableStream(children, { signal: request.signal, + importMap: router.options.ssr?.importMap, nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, onError: (error, info) => { @@ -154,6 +161,7 @@ export const renderRouterToStream = async ({ try { pipeable = ReactDOMServer.renderToPipeableStream(children, { + importMap: router.options.ssr?.importMap, nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, ...(isbot(request.headers.get('User-Agent')) diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index f42456b7bec..ee14e06d2d4 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -52,6 +52,68 @@ function unwrapResponse( } describe('renderRouterToStream - pipeable sync errors', () => { + test('passes the import map to readable stream rendering', async () => { + const importMap = { + imports: { package: '/package.js' }, + integrity: { '/package.js': 'sha384-readable' }, + scopes: { '/scope/': { dependency: '/dependency.js' } }, + } + const stream = Object.assign( + new ReadableStream({ + start(controller) { + controller.close() + }, + }), + { allReady: Promise.resolve() }, + ) + reactDomServerMocks.renderToReadableStream = vi.fn( + (_children?: unknown, options?: { importMap?: typeof importMap }) => { + expect(options?.importMap).toBe(importMap) + return stream + }, + ) + + const router = await buildRouter() + router.options.ssr = { importMap } + try { + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }) + } finally { + router.serverSsr?.cleanup() + } + }) + + test('passes the import map to pipeable stream rendering', async () => { + const importMap = { + imports: { package: '/package.js' }, + integrity: { '/package.js': 'sha384-pipeable' }, + scopes: { '/scope/': { dependency: '/dependency.js' } }, + } + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, options) => { + expect(options.importMap).toBe(importMap) + return { abort: vi.fn(), pipe: vi.fn() } + }, + ) + + const router = await buildRouter() + router.options.ssr = { importMap } + try { + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }) + } finally { + router.serverSsr?.cleanup() + } + }) + test('request abort cancels readable rendering without consuming the response body', async () => { const cancel = vi.fn() const stream = Object.assign(new ReadableStream({ cancel }), { diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index a296629ffa3..56f789e01e3 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -261,6 +261,8 @@ export type { RouterEvents, MatchRoutesOpts, RouterOptionsExtensions, + RouterSsrOptions, + RouterSsrOptionsExtensions, DefaultRemountDepsFn, PreloadRouteFn, MatchRouteFn, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index f2543892677..c8a4ae6da28 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -17,7 +17,13 @@ import type { } from './Matches' import type { RootRouteId } from './root' import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo' -import type { AnyRouter, Register, RegisteredRouter, SSROption } from './router' +import type { + AnyRouter, + Register, + RegisteredRouter, + RouterSsrOptions, + SSROption, +} from './router' import type { BuildLocationFn, NavigateFn } from './RouterProvider' import type { Assign, @@ -1203,9 +1209,7 @@ type AssetFnContextOptions< in out TBeforeLoadFn, in out TLoaderDeps, > = { - ssr?: { - nonce?: string - } + ssr?: RouterSsrOptions matches: Array< RouteMatch< TRouteId, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 13e7ad329c8..4d7af3f15c7 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -165,6 +165,12 @@ export interface DefaultRouterOptionsExtensions {} export interface RouterOptionsExtensions extends DefaultRouterOptionsExtensions {} +export interface RouterSsrOptionsExtensions {} + +export interface RouterSsrOptions extends RouterSsrOptionsExtensions { + nonce?: string +} + export type SSROption = boolean | 'data-only' export interface RouterOptions< @@ -514,9 +520,7 @@ export interface RouterOptions< */ rewrite?: LocationRewrite origin?: string - ssr?: { - nonce?: string - } + ssr?: RouterSsrOptions } export type LocationRewrite = {