diff --git a/.changeset/ssr-isbot-option.md b/.changeset/ssr-isbot-option.md new file mode 100644 index 0000000000..10f6c0a8d5 --- /dev/null +++ b/.changeset/ssr-isbot-option.md @@ -0,0 +1,34 @@ +--- +'@tanstack/react-router': minor +'@tanstack/solid-router': minor +'@tanstack/vue-router': minor +--- + +feat: add `isBot` option to `renderRouterToStream` to configure streaming SSR bot detection + +Streaming SSR waits for the full document (React's `allReady`) for requests that `isbot` flags by their `User-Agent`, and streams the shell first for everyone else. This was hardcoded, so performance auditors (Lighthouse, PageSpeed Insights, WebPageTest, …) — which `isbot` classifies as bots — were measured on the buffered path instead of the streaming path real users get. + +`renderRouterToStream` now accepts an `isBot` option so the decision can be made in the server request handler (it lives in `ssr/server` and never ships to the client). The default is unchanged. + +- `undefined` (default): use the built-in `isbot` User-Agent check. +- `boolean`: force the bot (`true`) or non-bot (`false`) path for the request. +- `(request) => boolean`: provide a custom predicate. + +```tsx +import { + createRequestHandler, + renderRouterToStream, + RouterServer, +} from '@tanstack/react-router/ssr/server' + +createRequestHandler({ request, createRouter })( + ({ request, router, responseHeaders }) => + renderRouterToStream({ + request, + router, + responseHeaders, + children: , + isBot: false, + }), +) +``` diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 46275a5421..6aa1424f57 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -46,12 +46,24 @@ export const renderRouterToStream = async ({ router, responseHeaders, children, + isBot, }: { request: Request router: AnyRouter responseHeaders: Headers children: ReactNode + /** + * Whether to treat the request as a bot/crawler. Bots wait for the full + * document (React's `allReady`) before responding instead of streaming the + * shell first. Defaults to the built-in `isbot` User-Agent check. + */ + isBot?: boolean | ((request: Request) => boolean) }) => { + const isBotRequest = + typeof isBot === 'function' + ? isBot(request) + : (isBot ?? isbot(request.headers.get('User-Agent'))) + if (typeof ReactDOMServer.renderToReadableStream === 'function') { const stream = await ReactDOMServer.renderToReadableStream(children, { signal: request.signal, @@ -64,7 +76,7 @@ export const renderRouterToStream = async ({ }, }) - if (isbot(request.headers.get('User-Agent'))) { + if (isBotRequest) { await waitForReadyOrAbort(stream.allReady, request.signal) } @@ -150,7 +162,7 @@ export const renderRouterToStream = async ({ pipeable = ReactDOMServer.renderToPipeableStream(children, { nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, - ...(isbot(request.headers.get('User-Agent')) + ...(isBotRequest ? { onAllReady() { pipeable!.pipe(reactAppPassthrough) diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index 74e180a552..893ac6104a 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -232,3 +232,82 @@ describe('renderRouterToStream - pipeable sync errors', () => { } }) }) + +describe('renderRouterToStream - bot detection (isBot option)', () => { + const BOT_UA = 'Googlebot/2.1 (+http://www.google.com/bot.html)' + const HUMAN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + + function requestWith(headers: Record) { + return new Request('http://localhost/', { headers }) + } + + // Captures whether renderToPipeableStream was given `onAllReady` (bot, wait + // for the full document) or `onShellReady` (stream the shell first). + async function getStreamMode( + request: Request, + isBot?: boolean | ((request: Request) => boolean), + ): Promise<'allReady' | 'shellReady' | undefined> { + const router = await buildRouter() + let mode: 'allReady' | 'shellReady' | undefined + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, opts) => { + mode = opts.onAllReady ? 'allReady' : 'shellReady' + queueMicrotask(() => (opts.onAllReady ?? opts.onShellReady)()) + return { abort: vi.fn(), pipe: vi.fn() } + }, + ) + + const result = await renderRouterToStream({ + request, + router, + responseHeaders: new Headers(), + children: null, + isBot, + }) + try { + return mode + } finally { + await unwrapResponse(result) + .body?.cancel() + .catch(() => {}) + router.serverSsr?.cleanup() + } + } + + test('default: bot User-Agent waits for allReady', async () => { + expect(await getStreamMode(requestWith({ 'user-agent': BOT_UA }))).toBe( + 'allReady', + ) + }) + + test('default: human User-Agent streams the shell', async () => { + expect(await getStreamMode(requestWith({ 'user-agent': HUMAN_UA }))).toBe( + 'shellReady', + ) + }) + + test('isBot=false: bot User-Agent still streams the shell', async () => { + expect( + await getStreamMode(requestWith({ 'user-agent': BOT_UA }), false), + ).toBe('shellReady') + }) + + test('isBot=true: human User-Agent waits for allReady', async () => { + expect( + await getStreamMode(requestWith({ 'user-agent': HUMAN_UA }), true), + ).toBe('allReady') + }) + + test('isBot predicate receives the request and controls the mode', async () => { + const isBot = vi.fn( + (request: Request) => request.headers.get('x-prerender') === '1', + ) + const request = requestWith({ 'user-agent': HUMAN_UA, 'x-prerender': '1' }) + + const mode = await getStreamMode(request, isBot) + + expect(mode).toBe('allReady') + expect(isBot).toHaveBeenCalledWith(request) + }) +}) diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 8914c117d9..0e7c034106 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -38,12 +38,24 @@ export const renderRouterToStream = async ({ router, responseHeaders, children, + isBot, }: { request: Request router: AnyRouter responseHeaders: Headers children: () => JSXElement + /** + * Whether to treat the request as a bot/crawler. Bots wait for the full + * server render before streaming. Defaults to the built-in `isbot` + * User-Agent check. + */ + isBot?: boolean | ((request: Request) => boolean) }) => { + const isBotRequest = + typeof isBot === 'function' + ? isBot(request) + : (isBot ?? isbot(request.headers.get('User-Agent'))) + const { writable, readable } = new TransformStream() const docType = Solid.ssr('') @@ -121,7 +133,7 @@ export const renderRouterToStream = async ({ }) } - if (isbot(request.headers.get('User-Agent'))) { + if (isBotRequest) { await waitForReadyOrAbort( Promise.resolve(stream as unknown), request.signal, diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index efad4fc2eb..ba6f7b3b49 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -68,15 +68,27 @@ export const renderRouterToStream = async ({ router, responseHeaders, App, + isBot, }: { request: Request router: AnyRouter responseHeaders: Headers App: Component + /** + * Whether to treat the request as a bot/crawler. Bots wait for the full + * document before streaming. Defaults to the built-in `isbot` User-Agent + * check. + */ + isBot?: boolean | ((request: Request) => boolean) }) => { const app = Vue.createSSRApp(App, { router }) - if (isbot(request.headers.get('User-Agent'))) { + const isBotRequest = + typeof isBot === 'function' + ? isBot(request) + : (isBot ?? isbot(request.headers.get('User-Agent'))) + + if (isBotRequest) { try { let cleanupAbortListener: (() => void) | undefined const abortPromise = new Promise((_, reject) => {