From d34135985ea2151ac76564305315ef308ea403df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3mulo=20Ochoa=20Valez?= Date: Fri, 1 May 2026 15:13:27 +0200 Subject: [PATCH 01/17] feat: add deferred head loading --- .changeset/deferred-head-loading.md | 11 + docs/router/guide/document-head-management.md | 65 + .../.devcontainer/devcontainer.json | 3 + examples/react/start-deferred-head/.gitignore | 20 + .../react/start-deferred-head/.prettierignore | 4 + .../start-deferred-head/.vscode/settings.json | 11 + examples/react/start-deferred-head/README.md | 34 + .../react/start-deferred-head/package.json | 30 + .../start-deferred-head/src/routeTree.gen.ts | 86 ++ .../react/start-deferred-head/src/router.tsx | 11 + .../start-deferred-head/src/routes/__root.tsx | 64 + .../src/routes/deferred.tsx | 131 ++ .../start-deferred-head/src/routes/index.tsx | 26 + .../start-deferred-head/src/styles/app.css | 30 + .../start-deferred-head/src/utils/seo.ts | 33 + .../react/start-deferred-head/tsconfig.json | 21 + .../react/start-deferred-head/vite.config.ts | 22 + packages/react-router/package.json | 3 +- packages/react-router/src/Matches.tsx | 17 + .../src/ssr/renderRouterToStream.tsx | 5 +- packages/router-core/package.json | 1 + packages/router-core/src/defer.ts | 266 ++++ packages/router-core/src/load-matches.ts | 57 +- packages/router-core/src/route.ts | 32 +- packages/router-core/src/router.ts | 5 + .../src/ssr/createRequestHandler.ts | 1 + packages/router-core/src/ssr/ssr-client.ts | 60 +- packages/router-core/src/ssr/ssr-server.ts | 4 + .../tests/deferred-head-loading.test.ts | 1116 +++++++++++++++++ packages/solid-router/package.json | 3 +- packages/solid-router/src/Matches.tsx | 17 + .../src/ssr/renderRouterToStream.tsx | 3 +- .../src/createStartHandler.ts | 1 + packages/vue-router/package.json | 1 - packages/vue-router/src/Matches.tsx | 16 + .../src/ssr/renderRouterToStream.tsx | 3 +- pnpm-lock.yaml | 58 +- 37 files changed, 2225 insertions(+), 46 deletions(-) create mode 100644 .changeset/deferred-head-loading.md create mode 100644 examples/react/start-deferred-head/.devcontainer/devcontainer.json create mode 100644 examples/react/start-deferred-head/.gitignore create mode 100644 examples/react/start-deferred-head/.prettierignore create mode 100644 examples/react/start-deferred-head/.vscode/settings.json create mode 100644 examples/react/start-deferred-head/README.md create mode 100644 examples/react/start-deferred-head/package.json create mode 100644 examples/react/start-deferred-head/src/routeTree.gen.ts create mode 100644 examples/react/start-deferred-head/src/router.tsx create mode 100644 examples/react/start-deferred-head/src/routes/__root.tsx create mode 100644 examples/react/start-deferred-head/src/routes/deferred.tsx create mode 100644 examples/react/start-deferred-head/src/routes/index.tsx create mode 100644 examples/react/start-deferred-head/src/styles/app.css create mode 100644 examples/react/start-deferred-head/src/utils/seo.ts create mode 100644 examples/react/start-deferred-head/tsconfig.json create mode 100644 examples/react/start-deferred-head/vite.config.ts create mode 100644 packages/router-core/tests/deferred-head-loading.test.ts diff --git a/.changeset/deferred-head-loading.md b/.changeset/deferred-head-loading.md new file mode 100644 index 00000000000..a025bbe16a6 --- /dev/null +++ b/.changeset/deferred-head-loading.md @@ -0,0 +1,11 @@ +--- +'@tanstack/router-core': minor +'@tanstack/react-router': minor +'@tanstack/solid-router': minor +'@tanstack/vue-router': minor +'@tanstack/start-server-core': minor +--- + +Add support for deferred head loading. Returning a `Promise` in any of `meta`, `links`, or `styles` from `head()` lets the page render immediately and awaits the promise for crawlers so resolved tags appear in the initial response for SEO and social previews. On the client, `head()` is re-evaluated once the promises settle and `` updates with the resolved values. + +The same deferred behavior is also supported for `scripts` returned from `head()` and for the body scripts array returned from `routeOptions.scripts`, with `` updating once the promises settle. diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index 381462e20a5..901ac7a1c0a 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -7,6 +7,7 @@ Document head management is the process of managing the head, title, meta, link, - Automatic deduping of `title` and `meta` tags - Automatic loading/unloading of tags based on route visibility - A composable way to merge `title` and `meta` tags from nested routes +- Deferred loading of `title`, `meta`, `links`, and `scripts` tags without blocking the initial page render For full-stack applications that use Start, and even for single-page applications that use TanStack Router, managing the document head is a crucial part of any application for the following reasons: @@ -168,6 +169,70 @@ const rootRoute = createRootRoute({ +### Deferred Head Loading + +When head data depends on an async source, awaiting it inside your loader blocks the entire page render — even though users don't need meta tags to interact with the page. To avoid that, you can return a **Promise** in any of `meta`, `links`, `scripts`, or `styles` from `head()`, or in the body scripts array returned from `routeOptions.scripts`, and TanStack Router will: + +- Render the page immediately for users without blocking on the promise +- Await the promise for crawlers so resolved tags appear in the initial response for correct indexing and social previews +- Re-evaluate `head()` and `scripts()` on the client once the promise settles, so the resolved tags are committed via `` and `` without blocking navigation + + +To defer a tag, return the promise from your loader and pass it directly into any head array (or the body scripts array), alongside any static entries you already have. The promise can resolve to a single descriptor or an array of them — the router flattens the result into the surrounding array: + +```tsx +export const Route = createFileRoute('/product/$slug')({ + loader: ({ params }) => { + // Kick off the fetch, but do not await it + const dataPromise = fetchPageData(params.slug) + return { dataPromise } + }, + head: ({ loaderData }) => ({ + meta: [ + // Static — present in the initial response + { property: 'og:type', content: 'website' }, + { name: 'twitter:site', content: '@mysite' }, + // Deferred — streamed for users, awaited for bots + loaderData.dataPromise.then((data) => [ + { title: data.title }, + { name: 'description', content: data.description }, + { property: 'og:title', content: data.title }, + { property: 'og:image', content: data.imageUrl }, + ]), + ], + links: [ + { rel: 'icon', href: '/favicon.ico' }, + loaderData.dataPromise.then((data) => [ + { rel: 'canonical', href: data.canonicalUrl }, + ]), + ], + scripts: [ + loaderData.dataPromise.then((data) => [ + { + type: 'application/ld+json', + children: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: data.title, + description: data.description, + image: data.imageUrl, + url: data.canonicalUrl, + }), + }, + ]), + ]), + }), + // Body scripts can be deferred too — useful for analytics or third-party + // tags that depend on data fetched in the loader + scripts: ({ loaderData }) => [ + loaderData.dataPromise.then((data) => [ + { src: `/analytics.js?id=${data.analyticsId}`, async: true }, + ]), + ], + component: ProductPage, +}) +``` + ## Managing Body Scripts In addition to scripts that can be rendered in the `` tag, you can also render scripts in the `` tag using the `routeOptions.scripts` property. This is useful for loading scripts (even inline scripts) that require the DOM to be loaded, but before the main entry point of your application (which includes hydration if you're using Start or a full-stack implementation of TanStack Router). diff --git a/examples/react/start-deferred-head/.devcontainer/devcontainer.json b/examples/react/start-deferred-head/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..e5dafce1881 --- /dev/null +++ b/examples/react/start-deferred-head/.devcontainer/devcontainer.json @@ -0,0 +1,3 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:24" +} diff --git a/examples/react/start-deferred-head/.gitignore b/examples/react/start-deferred-head/.gitignore new file mode 100644 index 00000000000..6ab0517d9f3 --- /dev/null +++ b/examples/react/start-deferred-head/.gitignore @@ -0,0 +1,20 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.vercel +.output +.nitro +/build/ +/api/ +/server/build +/public/build# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +.tanstack \ No newline at end of file diff --git a/examples/react/start-deferred-head/.prettierignore b/examples/react/start-deferred-head/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/examples/react/start-deferred-head/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/examples/react/start-deferred-head/.vscode/settings.json b/examples/react/start-deferred-head/.vscode/settings.json new file mode 100644 index 00000000000..00b5278e580 --- /dev/null +++ b/examples/react/start-deferred-head/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/examples/react/start-deferred-head/README.md b/examples/react/start-deferred-head/README.md new file mode 100644 index 00000000000..19f1f9c95eb --- /dev/null +++ b/examples/react/start-deferred-head/README.md @@ -0,0 +1,34 @@ +# TanStack Start - Deferred Head Example + +This example demonstrates deferred head loading in TanStack Router: returning a `Promise` in `meta`, `links`, `scripts`, or `styles` so the page renders immediately while head data is fetched in the background. Crawlers receive fully-resolved tags in the initial response for correct indexing and social previews. + +The `/deferred` route fetches page data with a 2-second delay and uses the resolved values to set title, description, Open Graph tags, canonical/hreflang links, and JSON-LD structured data. + +- [TanStack Router Docs](https://tanstack.com/router) + +## Start a new project based on this example + +To start a new project based on this example, run: + +```sh +npx gitpick TanStack/router/tree/main/examples/react/start-deferred-head start-deferred-head +``` + +## Getting Started + +From your terminal: + +```sh +pnpm install +pnpm dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Build + +To build the app for production: + +```sh +pnpm build +``` diff --git a/examples/react/start-deferred-head/package.json b/examples/react/start-deferred-head/package.json new file mode 100644 index 00000000000..529fd17ba4f --- /dev/null +++ b/examples/react/start-deferred-head/package.json @@ -0,0 +1,30 @@ +{ + "name": "tanstack-start-example-deferred-head", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "@tanstack/react-router": "^1.168.25", + "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-start": "^1.167.50", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^22.5.4", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "nitro": "^3.0.260311-beta", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.0" + } +} diff --git a/examples/react/start-deferred-head/src/routeTree.gen.ts b/examples/react/start-deferred-head/src/routeTree.gen.ts new file mode 100644 index 00000000000..976c75bb8f0 --- /dev/null +++ b/examples/react/start-deferred-head/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as DeferredRouteImport } from './routes/deferred' +import { Route as IndexRouteImport } from './routes/index' + +const DeferredRoute = DeferredRouteImport.update({ + id: '/deferred', + path: '/deferred', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/deferred': typeof DeferredRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/deferred': typeof DeferredRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/deferred': typeof DeferredRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/deferred' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/deferred' + id: '__root__' | '/' | '/deferred' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + DeferredRoute: typeof DeferredRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/deferred': { + id: '/deferred' + path: '/deferred' + fullPath: '/deferred' + preLoaderRoute: typeof DeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + DeferredRoute: DeferredRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/react/start-deferred-head/src/router.tsx b/examples/react/start-deferred-head/src/router.tsx new file mode 100644 index 00000000000..335a55600fa --- /dev/null +++ b/examples/react/start-deferred-head/src/router.tsx @@ -0,0 +1,11 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + scrollRestoration: true, + }) + return router +} diff --git a/examples/react/start-deferred-head/src/routes/__root.tsx b/examples/react/start-deferred-head/src/routes/__root.tsx new file mode 100644 index 00000000000..41aae9a6907 --- /dev/null +++ b/examples/react/start-deferred-head/src/routes/__root.tsx @@ -0,0 +1,64 @@ +/// +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import * as React from 'react' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ...seo({ + title: 'Deferred Head Loading Example | TanStack Start', + description: + 'Demonstrates deferred head loading for async meta tags with streaming.', + }), + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + component: RootComponent, + shellComponent: RootDocument, +}) + +function RootDocument(props: { children: React.ReactNode }) { + return ( + + + + + + {props.children} + + + + ) +} + +function RootComponent() { + return ( + <> +
+ + Home (sync) + + + Deferred Head + +
+ + + + ) +} diff --git a/examples/react/start-deferred-head/src/routes/deferred.tsx b/examples/react/start-deferred-head/src/routes/deferred.tsx new file mode 100644 index 00000000000..fa499d1ade2 --- /dev/null +++ b/examples/react/start-deferred-head/src/routes/deferred.tsx @@ -0,0 +1,131 @@ +import { createFileRoute } from '@tanstack/react-router' + +/** + * Simulates fetching page data from a remote API. + */ +function fetchPageData() { + return new Promise<{ + title: string + description: string + ogTitle: string + ogDescription: string + canonicalUrl: string + alternates: Array<{ lang: string; url: string }> + breadcrumbs: Array<{ name: string; url: string }> + analyticsId: string + }>((resolve) => { + setTimeout(() => { + resolve({ + title: 'Deferred Page — Loaded Title', + description: + 'This description was loaded asynchronously after 2 seconds.', + ogTitle: 'OG: Deferred Page Loaded', + ogDescription: 'Deferred OG description.', + canonicalUrl: 'https://example.com/deferred', + alternates: [ + { lang: 'es', url: 'https://example.com/es/deferred' }, + { lang: 'en', url: 'https://example.com/en/deferred' }, + ], + breadcrumbs: [ + { name: 'Home', url: 'https://example.com/' }, + { name: 'Deferred', url: 'https://example.com/deferred' }, + ], + analyticsId: 'GA-DEFERRED-123', + }) + }, 2000) + }) +} + +export const Route = createFileRoute('/deferred')({ + loader: () => { + // Kick off the fetch in the loader — don't await it + const dataPromise = fetchPageData() + return { dataPromise } + }, + head: ({ loaderData }) => ({ + meta: [ + // Pass a promise that resolves to meta descriptors. + // Streamed for normal users, awaited for crawlers automatically. + loaderData?.dataPromise.then((data) => [ + { title: data.title }, + { name: 'description', content: data.description }, + { property: 'og:title', content: data.ogTitle }, + { property: 'og:description', content: data.ogDescription }, + { property: 'og:type', content: 'website' }, + ]), + ], + links: [ + // Static link — always present immediately + { rel: 'icon', href: '/favicon.ico' }, + // Deferred links — canonical and hreflang alternates + loaderData?.dataPromise.then((data) => [ + { rel: 'canonical', href: data.canonicalUrl }, + ...data.alternates.map((alt) => ({ + rel: 'alternate', + hrefLang: alt.lang, + href: alt.url, + })), + ]), + ], + scripts: [ + // Deferred structured data (JSON-LD) + loaderData?.dataPromise.then((data) => [ + { + type: 'application/ld+json', + children: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'WebPage', + name: data.title, + description: data.description, + url: data.canonicalUrl, + }), + }, + { + type: 'application/ld+json', + children: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: data.breadcrumbs.map((crumb, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: crumb.name, + item: crumb.url, + })), + }), + }, + ]), + ], + }), + // Body scripts are deferrable too: useful for analytics or third-party + // tags that need an id from the loader (ex.: multitenant pages) + // Streamed for users, awaited for crawlers — same semantics as head() entries. + scripts: ({ loaderData }) => [ + loaderData?.dataPromise.then((data) => [ + { + children: `console.log("analytics initialized:", "${data.analyticsId}")`, + }, + ]), + ], + component: DeferredComponent, +}) + +function DeferredComponent() { + return ( +
+

Deferred Head (2s)

+

+ This page fetches data in the loader and structures head tags in{' '} + head(). The page renders immediately — meta, links, and + scripts are streamed to the client after ~2 seconds. Crawlers (Facebook, + Twitter, etc.) get everything resolved in the initial HTML + automatically. +

+

+ Demonstrates deferred meta tags, link tags + (canonical, hreflang), script tags inside{' '} + head() (JSON-LD structured data), and deferred body{' '} + scripts (analytics) — all from a single async data source. +

+
+ ) +} diff --git a/examples/react/start-deferred-head/src/routes/index.tsx b/examples/react/start-deferred-head/src/routes/index.tsx new file mode 100644 index 00000000000..ee7717daea9 --- /dev/null +++ b/examples/react/start-deferred-head/src/routes/index.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { seo } from '~/utils/seo' + +export const Route = createFileRoute('/')({ + head: () => ({ + meta: [ + ...seo({ + title: 'Home — Sync Head', + description: 'This page has synchronous head meta tags.', + }), + ], + }), + component: HomeComponent, +}) + +function HomeComponent() { + return ( +
+

Home

+

+ This page uses synchronous head. The title is set immediately during + SSR. Check the page source to see "Home — Sync Head" in the HTML. +

+
+ ) +} diff --git a/examples/react/start-deferred-head/src/styles/app.css b/examples/react/start-deferred-head/src/styles/app.css new file mode 100644 index 00000000000..37c1f5a6e2d --- /dev/null +++ b/examples/react/start-deferred-head/src/styles/app.css @@ -0,0 +1,30 @@ +@import 'tailwindcss' source('../'); + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +@layer base { + html { + color-scheme: light dark; + } + + * { + @apply border-gray-200 dark:border-gray-800; + } + + html, + body { + @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; + } + + .using-mouse * { + outline: none !important; + } +} diff --git a/examples/react/start-deferred-head/src/utils/seo.ts b/examples/react/start-deferred-head/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/examples/react/start-deferred-head/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/examples/react/start-deferred-head/tsconfig.json b/examples/react/start-deferred-head/tsconfig.json new file mode 100644 index 00000000000..cc479423a17 --- /dev/null +++ b/examples/react/start-deferred-head/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2024"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2024", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/examples/react/start-deferred-head/vite.config.ts b/examples/react/start-deferred-head/vite.config.ts new file mode 100644 index 00000000000..65fa77fd66d --- /dev/null +++ b/examples/react/start-deferred-head/vite.config.ts @@ -0,0 +1,22 @@ +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { defineConfig } from 'vite' +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { nitro } from 'nitro/vite' + +export default defineConfig({ + server: { + port: 3000, + }, + resolve: { + tsconfigPaths: true, + }, + plugins: [ + tailwindcss(), + tanstackStart({ + srcDirectory: 'src', + }), + viteReact(), + nitro(), + ], +}) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 8f330407f70..77701824670 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -102,8 +102,7 @@ "dependencies": { "@tanstack/history": "workspace:*", "@tanstack/react-store": "^0.9.3", - "@tanstack/router-core": "workspace:*", - "isbot": "^5.1.22" + "@tanstack/router-core": "workspace:*" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 5036f6e7271..4f076ec51fd 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -29,6 +29,17 @@ import type { ToSubOptionsProps, } from '@tanstack/router-core' +/** + * Entry in a `head()` input array: a descriptor, or a promise that + * resolves to one or many descriptors (deferred head loading). The + * router resolves the promises before values reach the match, which is + * why `RouteMatchExtensions` doesn't include them. + */ +type ReactHeadEntry = + | T + | Promise | null | undefined> + | undefined + declare module '@tanstack/router-core' { export interface RouteMatchExtensions { meta?: Array @@ -37,6 +48,12 @@ declare module '@tanstack/router-core' { styles?: Array headScripts?: Array } + export interface HeadFnReturn { + meta?: Array> + links?: Array> + scripts?: Array> + styles?: Array> + } } /** diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 52327d3050a..44b08689963 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -1,6 +1,5 @@ import { PassThrough } from 'node:stream' import ReactDOMServer from 'react-dom/server' -import { isbot } from 'isbot' import { transformPipeableStreamWithRouter, transformReadableStreamWithRouter, @@ -27,7 +26,7 @@ export const renderRouterToStream = async ({ progressiveChunkSize: Number.POSITIVE_INFINITY, }) - if (isbot(request.headers.get('User-Agent'))) { + if (router.serverSsr?.isBot) { await stream.allReady } @@ -48,7 +47,7 @@ export const renderRouterToStream = async ({ const pipeable = ReactDOMServer.renderToPipeableStream(children, { nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, - ...(isbot(request.headers.get('User-Agent')) + ...(router.serverSsr?.isBot ? { onAllReady() { pipeable.pipe(reactAppPassthrough) diff --git a/packages/router-core/package.json b/packages/router-core/package.json index 65f8ece7d97..fc463d8aa27 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -184,6 +184,7 @@ "dependencies": { "@tanstack/history": "workspace:*", "cookie-es": "^3.0.0", + "isbot": "^5.1.22", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, diff --git a/packages/router-core/src/defer.ts b/packages/router-core/src/defer.ts index 2bae16301f0..924f6ba2c4f 100644 --- a/packages/router-core/src/defer.ts +++ b/packages/router-core/src/defer.ts @@ -1,4 +1,7 @@ import { defaultSerializeError } from './router' +import type { AnyRoute } from './route' +import type { AnyRouteMatch } from './Matches' +import type { RouterCore } from './router' /** * Well-known symbol used by {@link defer} to tag a promise with @@ -23,6 +26,269 @@ export type DeferredPromiseState = error: unknown } +/** + * Resolve promises in a deferrable descriptor array — the four `head()` + * fields (`meta`, `links`, `scripts`, `styles`) and body `scripts` from + * `route.options.scripts`. Entries may be plain descriptors or promises + * resolving to descriptors, useful when a tag depends on a deferred loader + * value. + * + * On SSR for crawlers (`serverSsr.isBot` true), promises are awaited so the + * tags land in the initial HTML. Otherwise promises are skipped here and + * surface later via a client re-evaluation pass. + * + * @param awaitClient Forces awaiting on the client. Used by the re-eval + * pass and hydration, where the originals have already settled. + * @internal Exported only for unit tests. Internal callers should use + * {@link processAllDeferredFields}. + */ +export async function processDeferredField( + router: RouterCore, + matchId: string, + arr: Array | unknown, + field: string, + awaitClient?: boolean, +): Promise | undefined> { + if (!Array.isArray(arr)) return undefined + if (!arrayHasPromise(arr)) return arr + + const shouldAwait = router.serverSsr + ? router.serverSsr.isBot + : !!awaitClient + + const result: Array = [] + + for (let i = 0; i < arr.length; i++) { + const entry = arr[i] + if (!(entry instanceof Promise)) { + result.push(entry) + continue + } + + const id = matchId + '|' + field + '|' + i + const resolved$ = Promise.resolve(entry) + + if (shouldAwait) { + try { + const resolved = await resolved$ + pushResolved(result, resolved) + } catch (err) { + console.error( + `Deferred ${field} promise rejected for "${id}":`, + err, + ) + } + } else { + // Skip: the caller schedules a re-eval once the original settles. + // The catch silences unhandled-rejection warnings on what would + // otherwise be an orphan promise. + resolved$.catch((err) => { + console.error( + `Deferred ${field} promise rejected for "${id}":`, + err, + ) + }) + } + } + return result +} + +function arrayHasPromise(arr: Array): boolean { + for (let i = 0; i < arr.length; i++) { + if (arr[i] instanceof Promise) return true + } + return false +} + +/** + * Sync probe: does any field in `fields` carry a deferred Promise entry? + * + * Lets callers skip the deferred-resolution Promise.all and the + * re-evaluation scheduling entirely on the (common) static-head path, + * without paying for a full {@link processAllDeferredFields} sweep. + */ +export function hasAnyDeferred(fields: DeferrableFields | undefined): boolean { + if (!fields) return false + return ( + (Array.isArray(fields.meta) && arrayHasPromise(fields.meta)) || + (Array.isArray(fields.links) && arrayHasPromise(fields.links)) || + (Array.isArray(fields.headScripts) && arrayHasPromise(fields.headScripts)) || + (Array.isArray(fields.styles) && arrayHasPromise(fields.styles)) || + (Array.isArray(fields.scripts) && arrayHasPromise(fields.scripts)) + ) +} + +/** + * Mirrors `processDeferredField`'s no-promise return shape: the array + * if `v` is one, otherwise `undefined`. Used by the fast path so static + * descriptors land on the match without a promise round-trip. + */ +export function toResolvedArray( + v: unknown, +): Array | undefined { + return Array.isArray(v) ? v : undefined +} + +/** + * Shape produced by `head()` and `route.options.scripts()` once the framework + * type augmentation in each `Matches.tsx` is applied: the four head-array + * fields (`meta`, `links`, `headScripts`, `styles`) plus body `scripts`. + * Each entry can be a descriptor or a `Promise` resolving to one or many. + */ +interface DeferrableFields { + meta?: unknown + links?: unknown + headScripts?: unknown + styles?: unknown + scripts?: unknown +} + +export function processAllDeferredFields( + router: RouterCore, + matchId: string, + fields: DeferrableFields | undefined, + awaitClient?: boolean, +): Promise<{ + meta: Array | undefined + links: Array | undefined + headScripts: Array | undefined + styles: Array | undefined + scripts: Array | undefined +}> { + return Promise.all([ + processDeferredField(router, matchId, fields?.meta, 'meta', awaitClient), + processDeferredField(router, matchId, fields?.links, 'links', awaitClient), + processDeferredField( + router, + matchId, + fields?.headScripts, + 'headScripts', + awaitClient, + ), + processDeferredField( + router, + matchId, + fields?.styles, + 'styles', + awaitClient, + ), + processDeferredField( + router, + matchId, + fields?.scripts, + 'scripts', + awaitClient, + ), + ]).then(([meta, links, headScripts, styles, scripts]) => ({ + meta, + links, + headScripts, + styles, + scripts, + })) +} + +/** + * Schedule a client-side re-evaluation of `head()` and + * `route.options.scripts()` once the pending deferred promises in `fields` + * have settled, committing the resolved values via `router.updateMatch` so + * `` and `` subscribers re-render. + * + * The re-eval is single-shot: if the second `head()` invocation returns + * another unresolved promise, it is awaited inline (via `awaitClient=true`) + * and committed in the same pass. Without this bound, a `head()` that + * produces fresh deferred promises on every call would loop forever. + * + * @param matchesSnapshot Hierarchy from the load that scheduled the re-eval. + * Passed back to `head()` instead of the live store because the user may + * have navigated away by the time the promises settle. + * @param source "load" or "hydrate" — only used to disambiguate error logs. + */ +export function scheduleDeferredReEval( + router: RouterCore, + matchId: string, + route: AnyRoute, + matchesSnapshot: Array, + fields: DeferrableFields, + source: 'load' | 'hydrate', +): void { + const promises: Array> = [ + ...promisesIn(fields.meta), + ...promisesIn(fields.links), + ...promisesIn(fields.headScripts), + ...promisesIn(fields.styles), + ...promisesIn(fields.scripts), + ] + if (promises.length === 0) return + + Promise.allSettled(promises).then(async () => { + const freshMatch = router.getMatch(matchId) + if (!freshMatch) return + const freshContext = { + ssr: router.options.ssr, + matches: matchesSnapshot, + match: freshMatch, + params: freshMatch.params, + loaderData: freshMatch.loaderData, + } + try { + const [freshHead, freshScripts] = await Promise.all([ + route.options.head?.(freshContext), + route.options.scripts?.(freshContext), + ]) + const resolved = await processAllDeferredFields( + router, + matchId, + { + meta: freshHead?.meta, + links: freshHead?.links, + headScripts: freshHead?.scripts, + styles: freshHead?.styles, + scripts: freshScripts, + }, + true, + ) + if (!router.getMatch(matchId)) return + router.updateMatch(matchId, (prev) => ({ + ...prev, + meta: resolved.meta, + links: resolved.links, + headScripts: resolved.headScripts, + styles: resolved.styles, + scripts: resolved.scripts, + })) + } catch (err) { + console.error( + `Error re-evaluating deferred head/scripts (${source}) for "${matchId}":`, + err, + ) + } + }) +} + +/** Return the Promise entries of `v` if it's an array, otherwise empty. */ +function promisesIn(v: unknown): Array> { + if (!Array.isArray(v)) return [] + const out: Array> = [] + for (let i = 0; i < v.length; i++) { + const e = v[i] + if (e instanceof Promise) out.push(e) + } + return out +} + +/** Push resolved value(s) into result array, flattening arrays. */ +function pushResolved(result: Array, resolved: unknown): void { + if (resolved == null) return + if (Array.isArray(resolved)) { + for (const v of resolved) { + if (v != null) result.push(v) + } + } else { + result.push(resolved) + } +} + export type DeferredPromise = Promise & { [TSR_DEFERRED_PROMISE]: DeferredPromiseState } diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 275fd70c60d..daae0c79b62 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -4,6 +4,12 @@ import { createControlledPromise, isPromise } from './utils' import { isNotFound } from './not-found' import { rootRouteId } from './root' import { isRedirect } from './redirect' +import { + hasAnyDeferred, + processAllDeferredFields, + scheduleDeferredReEval, + toResolvedArray, +} from './defer' import type { NotFoundError } from './not-found' import type { ParsedLocation } from './location' import type { @@ -589,20 +595,47 @@ const executeHead = ( route.options.head?.(assetContext), route.options.scripts?.(assetContext), route.options.headers?.(assetContext), - ]).then(([headFnContent, scripts, headers]) => { - const meta = headFnContent?.meta - const links = headFnContent?.links - const headScripts = headFnContent?.scripts - const styles = headFnContent?.styles + ]).then(async ([headFnContent, scriptsRaw, headers]) => { + const fields = { + meta: headFnContent?.meta, + links: headFnContent?.links, + headScripts: headFnContent?.scripts, + styles: headFnContent?.styles, + scripts: scriptsRaw, + } - return { - meta, - links, - headScripts, - headers, - scripts, - styles, + // Fast path: no deferred promises in any field. Skip the + // processAllDeferredFields Promise.all and the re-eval scheduling — + // both are no-ops in this case but each costs allocations per match. + if (!hasAnyDeferred(fields)) { + return { + meta: toResolvedArray(fields.meta), + links: toResolvedArray(fields.links), + headScripts: toResolvedArray(fields.headScripts), + styles: toResolvedArray(fields.styles), + scripts: toResolvedArray(fields.scripts), + headers, + } } + + const { meta, links, headScripts, styles, scripts } = + await processAllDeferredFields(inner.router, matchId, fields) + + // On the client, schedule a re-evaluation once the deferred promises + // resolve so tags update without blocking navigation. Body scripts + // are checked alongside head fields. + if (!inner.router.serverSsr) { + scheduleDeferredReEval( + inner.router, + matchId, + route, + inner.matches, + fields, + 'load', + ) + } + + return { meta, links, headScripts, styles, scripts, headers } }) } diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index b92401b652a..bc1311e55c3 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -234,6 +234,29 @@ type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray export type RouteLinkEntry = {} +/** + * Return shape of `route.options.head()`. Augmented per-framework with + * concrete element types (e.g. `React.JSX.IntrinsicElements['meta']`). + * + * Distinct from {@link RouteMatchExtensions} because the input allows + * `Promise` entries (deferred head loading), but those are resolved + * away before values land in `match.meta`. + */ +export interface HeadFnReturn { + meta?: unknown + links?: unknown + scripts?: unknown + styles?: unknown +} + +/** + * Return shape of `route.options.scripts()` (body scripts). Reuses + * {@link HeadFnReturn}'s `scripts` field so a `Promise` can be returned for + * deferred body scripts, the same way head() fields can be deferred. The + * router resolves the promises before values land in `match.scripts`. + */ +export type BodyScriptsFnReturn = HeadFnReturn['scripts'] + export type SearchValidator = | ValidatorObj | ValidatorFn @@ -1370,12 +1393,7 @@ export interface UpdatableRouteOptions< TBeforeLoadFn, TLoaderDeps >, - ) => Awaitable<{ - links?: AnyRouteMatch['links'] - scripts?: AnyRouteMatch['headScripts'] - meta?: AnyRouteMatch['meta'] - styles?: AnyRouteMatch['styles'] - }> + ) => Awaitable scripts?: ( ctx: AssetFnContextOptions< TRouteId, @@ -1389,7 +1407,7 @@ export interface UpdatableRouteOptions< TBeforeLoadFn, TLoaderDeps >, - ) => Awaitable + ) => Awaitable codeSplitGroupings?: Array< Array< | 'loader' diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index bd8a2898ec4..21a09801a56 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -783,6 +783,11 @@ export type ClearCacheFn = (opts?: { }) => void export interface ServerSsr { + /** + * Whether the incoming request looks like a bot/crawler, based on the + * User-Agent header. + */ + isBot?: boolean /** * Injects HTML synchronously into the stream. * Emits an onInjectedHtml event that listeners can handle. diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index 9cdff7ca7dd..2c262f44959 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -32,6 +32,7 @@ export function createRequestHandler({ attachRouterServerSsrUtils({ router, manifest: await getRouterManifest?.(), + request, }) // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 6b5623eae8e..6e0b5d2ec03 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -1,5 +1,11 @@ import { invariant } from '../invariant' import { isNotFound } from '../not-found' +import { + hasAnyDeferred, + processAllDeferredFields, + scheduleDeferredReEval, + toResolvedArray, +} from '../defer' import { createControlledPromise } from '../utils' import { hydrateSsrMatchId } from './ssr-match-id' import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './constants' @@ -218,15 +224,57 @@ export async function hydrate(router: AnyRouter): Promise { params: match.params, loaderData: match.loaderData, } - const headFnContent = await route.options.head?.(assetContext) + const [headFnContent, scriptsRaw] = await Promise.all([ + route.options.head?.(assetContext), + route.options.scripts?.(assetContext), + ]) + + const fields = { + meta: headFnContent?.meta, + links: headFnContent?.links, + headScripts: headFnContent?.scripts, + styles: headFnContent?.styles, + scripts: scriptsRaw, + } - const scripts = await route.options.scripts?.(assetContext) + // Fast path: no deferred promises in any field — skip the + // processAllDeferredFields Promise.all and the re-eval scheduling. + if (!hasAnyDeferred(fields)) { + match.meta = toResolvedArray(fields.meta) + match.links = toResolvedArray(fields.links) + match.headScripts = toResolvedArray(fields.headScripts) + match.styles = toResolvedArray(fields.styles) + match.scripts = toResolvedArray(fields.scripts) + return + } - match.meta = headFnContent?.meta - match.links = headFnContent?.links - match.headScripts = headFnContent?.scripts - match.styles = headFnContent?.styles + // First pass: commit static entries only and skip pending + // promises so hydration never blocks. Start's streaming SSR + // typically inlines the resolved loader value before the + // client entry runs, but a non-Start consumer may hand us a + // still-pending promise — in either case the page becomes + // interactive immediately and the resolved values land via + // the re-evaluation pass below. + const { meta, links, headScripts, styles, scripts } = + await processAllDeferredFields(router, match.id, fields) + + match.meta = meta + match.links = links + match.headScripts = headScripts + match.styles = styles match.scripts = scripts + + // Schedule a re-evaluation once the deferred promises settle. + // Shared with `executeHead` so the load and hydrate paths commit + // deferred values the same way. + scheduleDeferredReEval( + router, + match.id, + route, + activeMatches, + fields, + 'hydrate', + ) } catch (err) { if (isNotFound(err)) { match.error = { isNotFound: true } diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 2bca7009d9e..0d60f1a2f51 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -1,4 +1,5 @@ import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' +import { isbot } from 'isbot' import { invariant } from '../invariant' import { createInlineCssPlaceholderAsset, @@ -282,11 +283,13 @@ function stripInlinedStylesheetAssets( export function attachRouterServerSsrUtils({ router, manifest, + request, getRequestAssets, includeUnmatchedRouteAssets = true, }: { router: AnyRouter manifest: Manifest | undefined + request?: Request getRequestAssets?: () => Array | undefined includeUnmatchedRouteAssets?: boolean }) { @@ -323,6 +326,7 @@ export function attachRouterServerSsrUtils({ let injectedHtmlBuffer = '' router.serverSsr = { + isBot: request ? isbot(request.headers.get('user-agent')) : undefined, injectHtml: (html: string) => { if (!html) return // Buffer the HTML so it can be retrieved via takeBufferedHtml() diff --git a/packages/router-core/tests/deferred-head-loading.test.ts b/packages/router-core/tests/deferred-head-loading.test.ts new file mode 100644 index 00000000000..3cb162f0800 --- /dev/null +++ b/packages/router-core/tests/deferred-head-loading.test.ts @@ -0,0 +1,1116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { processDeferredField, scheduleDeferredReEval } from '../src/defer' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { ServerSsr } from '../src/router' + +function createServerRouter(opts: { isBot: boolean }) { + const router = createTestRouter({ + routeTree: new BaseRootRoute({}), + history: createMemoryHistory(), + isServer: true, + }) + const serverSsr = { + injectHtml: vi.fn(), + isBot: opts.isBot, + } as unknown as ServerSsr + return Object.assign(router, { serverSsr }) +} + +function createClientRouter() { + return createTestRouter({ + routeTree: new BaseRootRoute({}), + history: createMemoryHistory(), + }) +} + +describe('processDeferredField', () => { + describe('SSR: non-bot users', () => { + it('skips deferred entries from the initial result (client re-eval will surface them)', async () => { + const router = createServerRouter({ isBot: false }) + + const meta = [ + { name: 'twitter:site', content: '@mysite' }, + Promise.resolve([ + { title: 'Async Title' }, + { property: 'og:title', content: 'OG Title' }, + ]), + ] + + const result = await processDeferredField( + router, + '/product/shoe', + meta, + 'meta', + ) + + expect(result).toEqual([{ name: 'twitter:site', content: '@mysite' }]) + expect(router.serverSsr.injectHtml).not.toHaveBeenCalled() + }) + }) + + describe('SSR: bot crawlers', () => { + it('awaits promises and returns resolved values inline (Googlebot)', async () => { + const router = createServerRouter({ isBot: true }) + + const meta = [ + { name: 'robots', content: 'index' }, + Promise.resolve([ + { title: 'Product Name' }, + { property: 'og:title', content: 'OG Product' }, + ]), + ] + + const result = await processDeferredField( + router, + '/product/1', + meta, + 'meta', + ) + + expect(result).toEqual([ + { name: 'robots', content: 'index' }, + { title: 'Product Name' }, + { property: 'og:title', content: 'OG Product' }, + ]) + + // Bots receive everything in the initial render. No streaming. + expect(router.serverSsr.injectHtml).not.toHaveBeenCalled() + }) + + it('flattens resolved arrays into the surrounding head array (Twitterbot)', async () => { + const router = createServerRouter({ isBot: true }) + + const links = [ + { rel: 'icon', href: '/icon.png' }, + Promise.resolve([ + { rel: 'canonical', href: 'https://example.com/page' }, + { + rel: 'alternate', + hrefLang: 'es', + href: 'https://example.com/es/page', + }, + ]), + ] + + const result = await processDeferredField( + router, + '/page', + links, + 'links', + ) + + expect(result).toEqual([ + { rel: 'icon', href: '/icon.png' }, + { rel: 'canonical', href: 'https://example.com/page' }, + { + rel: 'alternate', + hrefLang: 'es', + href: 'https://example.com/es/page', + }, + ]) + }) + }) + + describe('client navigation (non-blocking)', () => { + it('skips deferred entries and returns only the static ones', async () => { + const router = createClientRouter() + + const meta = [ + { name: 'viewport', content: 'width=device-width' }, + Promise.resolve([{ title: 'Deferred' }]), + ] + + const result = await processDeferredField( + router, + '/page', + meta, + 'meta', + ) + + expect(result).toEqual([ + { name: 'viewport', content: 'width=device-width' }, + ]) + }) + + it('awaitClient: includes resolved deferred values in the result', async () => { + // Re-evaluation pass after the original loader promise has settled. + // The new `.then()` chain in head() resolves on the next microtask, + // and we need its value in match.meta so the UI updates. + const router = createClientRouter() + const dataPromise = Promise.resolve({ title: 'Loaded' }) + await dataPromise + + const meta = [ + { name: 'twitter:site', content: '@mysite' }, + dataPromise.then((data) => [ + { title: data.title }, + { name: 'description', content: 'desc' }, + ]), + ] + + const result = await processDeferredField( + router, + '/p', + meta, + 'meta', + true, + ) + + expect(result).toEqual([ + { name: 'twitter:site', content: '@mysite' }, + { title: 'Loaded' }, + { name: 'description', content: 'desc' }, + ]) + }) + + it('awaitClient: logs and skips a rejected promise but keeps other entries', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const router = createClientRouter() + + const meta = [ + { name: 'static', content: 'present' }, + Promise.reject(new Error('upstream down')), + ] + + const result = await processDeferredField( + router, + '/p', + meta, + 'meta', + true, + ) + + expect(result).toEqual([{ name: 'static', content: 'present' }]) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Deferred meta promise rejected'), + expect.any(Error), + ) + consoleSpy.mockRestore() + }) + }) + + describe('error handling', () => { + it('logs and skips the rejected entry without losing the rest (SSR bots)', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const router = createServerRouter({ isBot: true }) + + const links = [ + { rel: 'icon', href: '/icon.png' }, + Promise.reject(new Error('upstream down')), + ] + + const result = await processDeferredField( + router, + '/page', + links, + 'links', + ) + + expect(result).toEqual([{ rel: 'icon', href: '/icon.png' }]) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Deferred links promise rejected'), + expect.any(Error), + ) + consoleSpy.mockRestore() + }) + + }) + + describe('edge cases', () => { + it('returns undefined when input is not an array', async () => { + const router = createClientRouter() + expect( + await processDeferredField(router, '/p', undefined, 'meta'), + ).toBeUndefined() + expect( + await processDeferredField(router, '/p', 'not an array', 'links'), + ).toBeUndefined() + }) + + it('returns the array unchanged when no entries are promises', async () => { + const router = createServerRouter({ isBot: false }) + const meta = [{ title: 'Hello' }, { name: 'desc', content: 'World' }] + const result = await processDeferredField(router, '/p', meta, 'meta') + expect(result).toEqual(meta) + }) + + it('drops null resolved values', async () => { + const router = createServerRouter({ isBot: true }) + const arr = [Promise.resolve(null)] + const result = await processDeferredField(router, '/p', arr, 'links') + expect(result).toHaveLength(0) + }) + + it('accepts a promise that resolves to a single descriptor (no array)', async () => { + const router = createServerRouter({ isBot: true }) + const arr = [Promise.resolve({ rel: 'stylesheet', href: '/single.css' })] + const result = await processDeferredField(router, '/p', arr, 'links') + expect(result).toEqual([{ rel: 'stylesheet', href: '/single.css' }]) + }) + }) +}) + +describe('executeHead: client re-evaluation through loadMatches', () => { + it('first pass returns only static entries; deferred entries land on match after the re-eval', async () => { + const dataPromise = createControlledPromise<{ title: string }>() + + const headFn = vi.fn(({ loaderData }: { loaderData?: any }) => ({ + meta: [ + { name: 'twitter:site', content: '@mysite' }, + loaderData?.dataPromise.then((data: { title: string }) => [ + { title: data.title }, + { name: 'description', content: 'deferred' }, + ]), + ], + })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ dataPromise }), + head: headFn, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const indexMatchId = router.state.matches.find((m) => m.routeId === '/')!.id + let match = router.getMatch(indexMatchId)! + expect(match.meta).toEqual([{ name: 'twitter:site', content: '@mysite' }]) + expect(headFn).toHaveBeenCalledTimes(1) + + dataPromise.resolve({ title: 'Loaded' }) + await new Promise((r) => setTimeout(r, 10)) + + expect(headFn).toHaveBeenCalledTimes(2) + + match = router.getMatch(indexMatchId)! + expect(match.meta).toEqual([ + { name: 'twitter:site', content: '@mysite' }, + { title: 'Loaded' }, + { name: 'description', content: 'deferred' }, + ]) + }) + + it('does not schedule a re-evaluation when head() has no deferred entries', async () => { + const headFn = vi.fn(() => ({ + meta: [{ name: 'description', content: 'static' }], + })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: headFn, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await new Promise((r) => setTimeout(r, 10)) + expect(headFn).toHaveBeenCalledTimes(1) + }) + + it('safely updates a cached match when deferred promises settle after the user navigated away', async () => { + const dataPromise = createControlledPromise<{ title: string }>() + + const rootRoute = new BaseRootRoute({}) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => ({ dataPromise }), + head: ({ loaderData }: { loaderData?: any }) => ({ + meta: [ + { name: 'static', content: 'present' }, + loaderData?.dataPromise.then((d: { title: string }) => [ + { title: d.title }, + ]), + ], + }), + }) + const fastRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fast', + head: () => ({ meta: [{ name: 'description', content: 'fast page' }] }), + }) + const routeTree = rootRoute.addChildren([slowRoute, fastRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/slow'] }), + }) + await router.load() + + await router.navigate({ to: '/fast' }) + await router.load() + + dataPromise.resolve({ title: 'Late arrival' }) + await new Promise((r) => setTimeout(r, 10)) + + const fastMatch = router.state.matches.find((m) => m.routeId === '/fast')! + expect(fastMatch.meta).toEqual([ + { name: 'description', content: 'fast page' }, + ]) + }) + + // Re-eval re-runs head() and scripts() but does not re-run headers(). + // headers() is a server-only response-headers contract; recomputing it on the + // client would be both wasteful and meaningless. + it('does not re-invoke headers() during client re-evaluation', async () => { + const dataPromise = createControlledPromise<{ title: string }>() + + const headFn = vi.fn(({ loaderData }: { loaderData?: any }) => ({ + meta: [ + { name: 'static', content: 'present' }, + loaderData?.dataPromise.then((d: { title: string }) => [ + { title: d.title }, + ]), + ], + })) + const headersFn = vi.fn(() => ({ 'x-custom': 'value' })) + const scriptsFn = vi.fn(() => []) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ dataPromise }), + head: headFn, + headers: headersFn, + scripts: scriptsFn, + } as any) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + expect(headFn).toHaveBeenCalledTimes(1) + expect(headersFn).toHaveBeenCalledTimes(1) + expect(scriptsFn).toHaveBeenCalledTimes(1) + + dataPromise.resolve({ title: 'Loaded' }) + await new Promise((r) => setTimeout(r, 10)) + + // head() and scripts() re-run on the re-eval pass; headers() must not. + expect(headFn).toHaveBeenCalledTimes(2) + expect(scriptsFn).toHaveBeenCalledTimes(2) + expect(headersFn).toHaveBeenCalledTimes(1) + }) + + // The re-eval pass passes the snapshot of `inner.matches` from the + // load that started it. Even after a follow-up navigation, the second head() + // call still receives the original matches array. + it('passes the same matches snapshot to head() on the re-evaluation pass', async () => { + const dataPromise = createControlledPromise<{ title: string }>() + + const matchesSeen: Array> = [] + const headFn = ({ + matches, + loaderData, + }: { + matches: Array + loaderData?: any + }) => { + matchesSeen.push(matches) + return { + meta: [ + { name: 'static', content: 'present' }, + loaderData?.dataPromise.then((d: { title: string }) => [ + { title: d.title }, + ]), + ], + } + } + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ dataPromise }), + head: headFn, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + expect(matchesSeen).toHaveLength(1) + + dataPromise.resolve({ title: 'Loaded' }) + await new Promise((r) => setTimeout(r, 10)) + + expect(matchesSeen).toHaveLength(2) + // Same array reference: re-eval reuses the original load's snapshot + // rather than reading the live (possibly-stale) match store. + expect(matchesSeen[1]).toBe(matchesSeen[0]) + }) + + // If the re-evaluation's head() returns *new* unresolved promises, + // they are awaited under awaitClient=true and their resolved values land on + // the match. (Practically only happens when the user creates fresh promises + // inside head() — uncommon, but the contract should still hold.) + it('awaits new unresolved promises returned during the re-evaluation pass', async () => { + const originalPromise = createControlledPromise<{ title: string }>() + const secondPromise = createControlledPromise>() + + let callCount = 0 + const headFn = vi.fn(({ loaderData }: { loaderData?: any }) => { + callCount++ + if (callCount === 1) { + return { + meta: [ + { name: 'static', content: 'present' }, + loaderData?.dataPromise.then(() => [{ title: 'first-pass' }]), + ], + } + } + // Re-eval pass returns a brand-new unresolved promise that the + // awaitClient=true path must await before updating the match. + return { + meta: [{ name: 'static', content: 'present' }, secondPromise], + } + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ dataPromise: originalPromise }), + head: headFn, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const indexMatchId = router.state.matches.find((m) => m.routeId === '/')!.id + + // Trigger re-evaluation by resolving the original deferred promise. + originalPromise.resolve({ title: 'unused' }) + // Yield enough microtasks for the re-eval to start and call head() again, + // but not enough for it to finish (secondPromise is still pending). + await new Promise((r) => setTimeout(r, 10)) + + expect(headFn).toHaveBeenCalledTimes(2) + // Match should still hold the first-pass static entries; re-eval is + // blocked on secondPromise. + expect(router.getMatch(indexMatchId)!.meta).toEqual([ + { name: 'static', content: 'present' }, + ]) + + // Resolve the new promise — re-eval should now finish and commit it. + secondPromise.resolve([{ title: 'second-pass' }]) + await new Promise((r) => setTimeout(r, 10)) + + expect(router.getMatch(indexMatchId)!.meta).toEqual([ + { name: 'static', content: 'present' }, + { title: 'second-pass' }, + ]) + }) +}) + +describe('executeHead: deferred body scripts', () => { + it('first pass commits only static body scripts; deferred entries land after re-eval', async () => { + const dataPromise = createControlledPromise<{ src: string }>() + + const scriptsFn = vi.fn(({ loaderData }: { loaderData?: any }) => [ + { children: 'console.log("static")' }, + loaderData?.dataPromise.then((d: { src: string }) => [{ src: d.src }]), + ]) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ dataPromise }), + scripts: scriptsFn, + } as any) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const indexMatchId = router.state.matches.find((m) => m.routeId === '/')!.id + let match = router.getMatch(indexMatchId)! + expect(match.scripts).toEqual([{ children: 'console.log("static")' }]) + expect(scriptsFn).toHaveBeenCalledTimes(1) + + dataPromise.resolve({ src: '/analytics.js' }) + await new Promise((r) => setTimeout(r, 10)) + + expect(scriptsFn).toHaveBeenCalledTimes(2) + + match = router.getMatch(indexMatchId)! + expect(match.scripts).toEqual([ + { children: 'console.log("static")' }, + { src: '/analytics.js' }, + ]) + }) + + it('schedules a single re-eval when both head() and body scripts have deferred entries', async () => { + const dataPromise = + createControlledPromise<{ title: string; src: string }>() + + const headFn = vi.fn(({ loaderData }: { loaderData?: any }) => ({ + meta: [ + { name: 'static', content: 'present' }, + loaderData?.dataPromise.then((d: { title: string }) => [ + { title: d.title }, + ]), + ], + })) + const scriptsFn = vi.fn(({ loaderData }: { loaderData?: any }) => [ + { children: 'console.log("static")' }, + loaderData?.dataPromise.then((d: { src: string }) => [{ src: d.src }]), + ]) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ dataPromise }), + head: headFn, + scripts: scriptsFn, + } as any) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + expect(headFn).toHaveBeenCalledTimes(1) + expect(scriptsFn).toHaveBeenCalledTimes(1) + + dataPromise.resolve({ title: 'Loaded', src: '/late.js' }) + await new Promise((r) => setTimeout(r, 10)) + + expect(headFn).toHaveBeenCalledTimes(2) + expect(scriptsFn).toHaveBeenCalledTimes(2) + + const indexMatchId = router.state.matches.find((m) => m.routeId === '/')!.id + const match = router.getMatch(indexMatchId)! + expect(match.meta).toEqual([ + { name: 'static', content: 'present' }, + { title: 'Loaded' }, + ]) + expect(match.scripts).toEqual([ + { children: 'console.log("static")' }, + { src: '/late.js' }, + ]) + }) + + it('does not schedule a re-eval when body scripts contain no promises', async () => { + const scriptsFn = vi.fn(() => [{ children: 'console.log("static")' }]) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + scripts: scriptsFn, + } as any) + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await new Promise((r) => setTimeout(r, 10)) + expect(scriptsFn).toHaveBeenCalledTimes(1) + }) + + it('safely updates a cached match when deferred body scripts settle after the user navigated away', async () => { + const dataPromise = createControlledPromise<{ src: string }>() + + const rootRoute = new BaseRootRoute({}) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => ({ dataPromise }), + scripts: ({ loaderData }: { loaderData?: any }) => [ + { children: 'console.log("static")' }, + loaderData?.dataPromise.then((d: { src: string }) => [{ src: d.src }]), + ], + } as any) + const fastRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fast', + scripts: () => [{ children: 'console.log("fast")' }], + } as any) + const routeTree = rootRoute.addChildren([slowRoute, fastRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/slow'] }), + }) + await router.load() + + await router.navigate({ to: '/fast' }) + await router.load() + + dataPromise.resolve({ src: '/late.js' }) + await new Promise((r) => setTimeout(r, 10)) + + const fastMatch = router.state.matches.find((m) => m.routeId === '/fast')! + expect(fastMatch.scripts).toEqual([{ children: 'console.log("fast")' }]) + }) +}) + +// `processDeferredField`'s server branch keys off `router.serverSsr.isBot` +// and ignores the `awaitClient` argument: SSR semantics are determined by the +// request, not the caller. These tests pin that asymmetry so it doesn't drift. +describe('processDeferredField: awaitClient is ignored on the server', () => { + it('ignores awaitClient=true when serverSsr.isBot is false (skips deferred entry)', async () => { + const router = createServerRouter({ isBot: false }) + + const meta = [ + { name: 'static', content: 'present' }, + Promise.resolve([{ title: 'Deferred' }]), + ] + + const result = await processDeferredField( + router, + '/p', + meta, + 'meta', + true, + ) + + // Even though awaitClient=true was passed, the server branch wins and the + // deferred entry is skipped because the request is not a bot. + expect(result).toEqual([{ name: 'static', content: 'present' }]) + }) + + it('awaits when serverSsr.isBot is true regardless of awaitClient=false', async () => { + const router = createServerRouter({ isBot: true }) + + const meta = [ + { name: 'static', content: 'present' }, + Promise.resolve([{ title: 'Resolved' }]), + ] + + const result = await processDeferredField( + router, + '/p', + meta, + 'meta', + false, + ) + + // isBot wins regardless of awaitClient. + expect(result).toEqual([ + { name: 'static', content: 'present' }, + { title: 'Resolved' }, + ]) + }) +}) + +// `attachRouterServerSsrUtils` accepts an optional `request` and computes +// `serverSsr.isBot` from its User-Agent header so that both Start +// (`createStartHandler`) and non-Start (`createRequestHandler`) consumers +// get crawler-aware deferred head loading without manually setting `isBot`. +describe('attachRouterServerSsrUtils: automatic bot detection', () => { + function newServerRouter() { + return createTestRouter({ + routeTree: new BaseRootRoute({}), + history: createMemoryHistory(), + isServer: true, + }) + } + + it('flags a Googlebot User-Agent', () => { + const router: any = newServerRouter() + attachRouterServerSsrUtils({ + router, + manifest: undefined, + request: new Request('http://localhost/', { + headers: { + 'user-agent': + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + }, + }), + }) + expect(router.serverSsr.isBot).toBe(true) + }) + + it('does not flag a normal Chrome User-Agent', () => { + const router: any = newServerRouter() + attachRouterServerSsrUtils({ + router, + manifest: undefined, + request: new Request('http://localhost/', { + headers: { + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', + }, + }), + }) + expect(router.serverSsr.isBot).toBe(false) + }) + + it('treats a missing User-Agent header as not-a-bot', () => { + const router: any = newServerRouter() + attachRouterServerSsrUtils({ + router, + manifest: undefined, + request: new Request('http://localhost/'), + }) + // isbot only flags a UA when it matches a known crawler signature; + // an absent/empty UA is treated as a non-bot client. + expect(router.serverSsr.isBot).toBe(false) + }) + + it('leaves isBot undefined when no request is provided (legacy callers can still set it manually)', () => { + const router: any = newServerRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + expect(router.serverSsr.isBot).toBeUndefined() + }) +}) + +// `hydrate` mirrors `executeHead`: the first pass commits only static head +// entries so hydration is never blocked by a pending loader promise. If any +// field carried a promise, a `Promise.allSettled`-driven re-evaluation pass +// re-runs head()/scripts() and commits the resolved values through the store +// so `` / `` subscribers see them. +describe('hydrate: deferred head loading', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + let dataPromise: ReturnType< + typeof createControlledPromise<{ title: string; src: string }> + > + let mockRouter: any + let headFn: ReturnType + let scriptsFn: ReturnType + let originalWindow: unknown + + beforeEach(() => { + // Snapshot whatever the test environment (jsdom) had before so afterEach + // can restore it. Without this, deleting `global.window` here leaks into + // later tests that rely on jsdom's window for RouterCore.update(). + originalWindow = (global as any).window + mockWindow = {} + ;(global as any).window = mockWindow + dataPromise = createControlledPromise<{ title: string; src: string }>() + + headFn = vi.fn(({ loaderData }: { loaderData?: any }) => ({ + meta: [ + { name: 'static', content: 'present' }, + loaderData?.dataPromise.then((d: { title: string }) => [ + { title: d.title }, + ]), + ], + })) + + scriptsFn = vi.fn(({ loaderData }: { loaderData?: any }) => [ + { children: 'console.log("static")' }, + loaderData?.dataPromise.then((d: { src: string }) => [{ src: d.src }]), + ]) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: headFn, + scripts: scriptsFn, + } as any) + const routeTree = rootRoute.addChildren([indexRoute]) + + mockRouter = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + }) + + afterEach(() => { + vi.resetAllMocks() + if (originalWindow === undefined) { + delete (global as any).window + } else { + ;(global as any).window = originalWindow + } + }) + + function setupDehydrated(loaderData: any, router: any = mockRouter) { + const mockMatches: Array = [ + { id: '/', routeId: '/', index: 0, ssr: undefined, _nonReactive: {} }, + ] + router.matchRoutes = vi.fn().mockReturnValue(mockMatches) + router.state.matches = mockMatches + + mockWindow.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: '/', + matches: [ + { i: '/', l: loaderData, s: 'success', ssr: true, u: Date.now() }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } as any + + return mockMatches + } + + it('does not block hydration on a still-pending deferred head promise', async () => { + // A non-Start consumer hands hydrate a loader whose deferred promise + // has not yet settled. Hydrate must complete immediately and let the + // re-evaluation pass commit the resolved values when they arrive. + const mockMatches = setupDehydrated({ dataPromise }) + + let hydrationDone = false + const hydratePromise = hydrate(mockRouter).then(() => { + hydrationDone = true + }) + + // Yield enough microtasks for hydrate's async work to settle. + await new Promise((r) => setTimeout(r, 10)) + + expect(hydrationDone).toBe(true) + await hydratePromise + + // First pass committed static entries only — re-eval is queued behind + // the still-pending dataPromise. + expect(mockMatches[0].meta).toEqual([ + { name: 'static', content: 'present' }, + ]) + expect(mockMatches[0].scripts).toEqual([ + { children: 'console.log("static")' }, + ]) + expect(headFn).toHaveBeenCalledTimes(1) + expect(scriptsFn).toHaveBeenCalledTimes(1) + }) + + it('commits resolved values to the store after the pending promise settles', async () => { + setupDehydrated({ dataPromise }) + + await hydrate(mockRouter) + + dataPromise.resolve({ title: 'Late', src: '/late.js' }) + await new Promise((r) => setTimeout(r, 10)) + + // Re-eval re-runs head()/scripts() and commits via updateMatch, so the + // resolved values are visible through `router.getMatch`. + expect(headFn).toHaveBeenCalledTimes(2) + expect(scriptsFn).toHaveBeenCalledTimes(2) + + const matchAfter = mockRouter.getMatch('/')! + expect(matchAfter.meta).toEqual([ + { name: 'static', content: 'present' }, + { title: 'Late' }, + ]) + expect(matchAfter.scripts).toEqual([ + { children: 'console.log("static")' }, + { src: '/late.js' }, + ]) + }) + + it('eventually commits resolved values when promises were pre-settled (streaming-SSR happy path)', async () => { + // In Start's streaming SSR, the inline `' } }], + ]) + const ldScript = tags.find((t) => t.tag === 'script')! + expect(ldScript.children).not.toContain('') + expect(ldScript.children).toContain('\\u003c/script') + }) + + it('appends a csp-nonce meta tag and propagates the nonce to meta attrs', () => { + const tags = buildMetaTags( + [[{ name: 'description', content: 'hello' }]], + 'abc123', + ) + + const descMeta = tags.find( + (t) => t.tag === 'meta' && t.attrs?.name === 'description', + )! + expect(descMeta.attrs).toMatchObject({ nonce: 'abc123' }) + + const cspMeta = tags.find( + (t) => t.tag === 'meta' && t.attrs?.property === 'csp-nonce', + )! + expect(cspMeta.attrs).toMatchObject({ content: 'abc123' }) + }) +}) diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index 75586f862d1..f6becb132ff 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -24,11 +24,21 @@ import type { declare module '@tanstack/router-core' { export interface RouteMatchExtensions { - meta?: Array - links?: Array - scripts?: Array - styles?: Array - headScripts?: Array + meta?: Array< + (Solid.JSX.IntrinsicElements['meta'] & { key?: string }) | undefined + > + links?: Array< + (Solid.JSX.IntrinsicElements['link'] & { key?: string }) | undefined + > + scripts?: Array< + (Solid.JSX.IntrinsicElements['script'] & { key?: string }) | undefined + > + styles?: Array< + (Solid.JSX.IntrinsicElements['style'] & { key?: string }) | undefined + > + headScripts?: Array< + (Solid.JSX.IntrinsicElements['script'] & { key?: string }) | undefined + > } } diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx index da77c86191d..a83faf17bee 100644 --- a/packages/solid-router/src/headContentUtils.tsx +++ b/packages/solid-router/src/headContentUtils.tsx @@ -1,9 +1,12 @@ import * as Solid from 'solid-js' import { - escapeHtml, + buildMetaTags, + dedupByLastKey, getAssetCrossOrigin, + hashTag, isInlinableStylesheet, resolveManifestAssetLink, + uniqBy, } from '@tanstack/router-core' import { useRouter } from './useRouter' import type { @@ -25,91 +28,28 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { .filter(Boolean), ) - const meta: Solid.Accessor> = Solid.createMemo(() => { - const resultMeta: Array = [] - const metaByAttribute: Record = {} - let title: RouterManagedTag | undefined - const routeMetasArray = routeMeta() - for (let i = routeMetasArray.length - 1; i >= 0; i--) { - const metas = routeMetasArray[i]! - for (let j = metas.length - 1; j >= 0; j--) { - const m = metas[j] - if (!m) continue - - if (m.title) { - if (!title) { - title = { - tag: 'title', - children: m.title, - } - } - } else if ('script:ld+json' in m) { - // Handle JSON-LD structured data - // Content is HTML-escaped to prevent XSS when injected via innerHTML - try { - const json = JSON.stringify(m['script:ld+json']) - resultMeta.push({ - tag: 'script', - attrs: { - type: 'application/ld+json', - }, - children: escapeHtml(json), - }) - } catch { - // Skip invalid JSON-LD objects - } - } else { - const attribute = m.name ?? m.property - if (attribute) { - if (metaByAttribute[attribute]) { - continue - } else { - metaByAttribute[attribute] = true - } - } - - resultMeta.push({ - tag: 'meta', - attrs: { - ...m, - nonce, - }, - }) - } - } - } - - if (title) { - resultMeta.push(title) - } - - if (router.options.ssr?.nonce) { - resultMeta.push({ - tag: 'meta', - attrs: { - property: 'csp-nonce', - content: router.options.ssr.nonce, - }, - }) - } - resultMeta.reverse() - - return resultMeta - }) + const meta: Solid.Accessor> = Solid.createMemo(() => + buildMetaTags(routeMeta(), nonce), + ) const links = Solid.createMemo(() => { const matches = activeMatches() - const constructed = matches - .map((match) => match.links!) - .filter(Boolean) - .flat(1) - .map((link) => ({ + const constructed = dedupByLastKey( + matches + .map((match) => match.links!) + .flat(1) + .filter(Boolean) as Array, + ).map((link) => { + const { key, ...attrs } = link + return { tag: 'link', attrs: { - ...link, + ...attrs, nonce, }, - })) satisfies Array + key, + } + }) satisfies Array const manifest = router.ssr?.manifest @@ -186,34 +126,36 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { }) const styles = Solid.createMemo(() => - ( + dedupByLastKey( activeMatches() .map((match) => match.styles!) .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...style }) => ({ + .filter(Boolean) as Array, + ).map(({ children, key, ...attrs }) => ({ tag: 'style', attrs: { - ...style, + ...attrs, nonce, }, children, + key, })), ) const headScripts = Solid.createMemo(() => - ( + dedupByLastKey( activeMatches() .map((match) => match.headScripts!) .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ + .filter(Boolean) as Array, + ).map(({ children, key, ...script }) => ({ tag: 'script', attrs: { ...script, nonce, }, children, + key, })), ) @@ -226,9 +168,7 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { ...styles(), ...headScripts(), ] as Array, - (d) => { - return JSON.stringify(d) - }, + hashTag, ) if (prev === undefined) { return next @@ -243,12 +183,12 @@ function replaceEqualTags( ) { const prevByKey = new Map() for (const tag of prev) { - prevByKey.set(JSON.stringify(tag), tag) + prevByKey.set(hashTag(tag), tag) } let isEqual = prev.length === next.length const result = next.map((tag, index) => { - const existing = prevByKey.get(JSON.stringify(tag)) + const existing = prevByKey.get(hashTag(tag)) if (existing) { if (existing !== prev[index]) { isEqual = false @@ -262,15 +202,3 @@ function replaceEqualTags( return isEqual ? prev : result } - -export function uniqBy(arr: Array, fn: (item: T) => string) { - const seen = new Set() - return arr.filter((item) => { - const key = fn(item) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) -} diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index a9851aefdce..ee7b73194f2 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -25,10 +25,14 @@ type ErrorRouteComponentType = (props: ErrorComponentProps) => Vue.VNode declare module '@tanstack/router-core' { export interface RouteMatchExtensions { - meta?: Array - links?: Array - scripts?: Array - headScripts?: Array + meta?: Array<(Vue.ComponentOptions['meta'] & { key?: string }) | undefined> + links?: Array<(Vue.ComponentOptions['link'] & { key?: string }) | undefined> + scripts?: Array< + (Vue.ComponentOptions['script'] & { key?: string }) | undefined + > + headScripts?: Array< + (Vue.ComponentOptions['script'] & { key?: string }) | undefined + > } } diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index 79d2ee282f0..dfff462fac1 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -1,9 +1,12 @@ import * as Vue from 'vue' import { - escapeHtml, + buildMetaTags, + dedupByLastKey, getAssetCrossOrigin, + hashTag, isInlinableStylesheet, resolveManifestAssetLink, + uniqBy, } from '@tanstack/router-core' import { useStore } from '@tanstack/vue-store' import { useRouter } from './useRouter' @@ -16,79 +19,27 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() const matches = useStore(router.stores.matches, (value) => value) - const meta = Vue.computed>(() => { - const resultMeta: Array = [] - const metaByAttribute: Record = {} - let title: RouterManagedTag | undefined - ;[...matches.value.map((match) => match.meta!).filter(Boolean)] - .reverse() - .forEach((metas) => { - ;[...metas].reverse().forEach((m) => { - if (!m) return - - if (m.title) { - if (!title) { - title = { - tag: 'title', - children: m.title, - } - } - } else if ('script:ld+json' in m) { - // Handle JSON-LD structured data - // Content is HTML-escaped to prevent XSS when injected via innerHTML - try { - const json = JSON.stringify(m['script:ld+json']) - resultMeta.push({ - tag: 'script', - attrs: { - type: 'application/ld+json', - }, - children: escapeHtml(json), - }) - } catch { - // Skip invalid JSON-LD objects - } - } else { - const attribute = m.name ?? m.property - if (attribute) { - if (metaByAttribute[attribute]) { - return - } else { - metaByAttribute[attribute] = true - } - } - - resultMeta.push({ - tag: 'meta', - attrs: { - ...m, - }, - }) - } - }) - }) - - if (title) { - resultMeta.push(title) - } - - resultMeta.reverse() - - return resultMeta - }) + const meta = Vue.computed>(() => + buildMetaTags(matches.value.map((match) => match.meta!).filter(Boolean)), + ) const links = Vue.computed>( () => - matches.value - .map((match) => match.links!) - .filter(Boolean) - .flat(1) - .map((link) => ({ + dedupByLastKey( + matches.value + .map((match) => match.links!) + .flat(1) + .filter(Boolean) as Array, + ).map((link) => { + const { key, ...attrs } = link + return { tag: 'link', attrs: { - ...link, + ...attrs, }, - })) as Array, + key, + } + }) as Array, ) const preloadMeta = Vue.computed>(() => { @@ -118,17 +69,18 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { }) const headScripts = Vue.computed>(() => - ( + dedupByLastKey( matches.value .map((match) => match.headScripts!) .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ + .filter(Boolean) as Array, + ).map(({ children, key, ...script }) => ({ tag: 'script', attrs: { ...script, }, children, + key, })), ) @@ -184,20 +136,6 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { ...links.value, ...headScripts.value, ] as Array, - (d) => { - return JSON.stringify(d) - }, + hashTag, ) } - -export function uniqBy(arr: Array, fn: (item: T) => string) { - const seen = new Set() - return arr.filter((item) => { - const key = fn(item) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) -} From fa9f6602fcbcab00d0b2234735efdf116cca7297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3mulo=20Ochoa=20Valez?= Date: Sat, 2 May 2026 08:54:39 +0200 Subject: [PATCH 12/17] Fix docs and missing export --- docs/router/guide/document-head-management.md | 2 +- packages/router-core/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index 72ec36e4e52..c32737651e7 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -171,7 +171,7 @@ const rootRoute = createRootRoute({ ### Deferred Head Loading -When head data depends on an async source, awaiting it inside your loader blocks the entire page render — even though users don't need meta tags to interact with the page. To avoid that, you can return a **Promise** in any of `meta`, `links`, and `scripts` from `head()`, and TanStack Router will: +When head data depends on an async source, awaiting it inside your loader blocks the entire page render — even though users don't need meta tags to interact with the page. To avoid that, you can return a **Promise** in any of `meta`, `links`, `scripts`, and `styles` from `head()`, and TanStack Router will: - Render the page immediately for users without blocking on the promise - Await the promise for crawlers so resolved tags appear in the initial response for correct indexing and social previews diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index f08a769cb56..76f593dc6e4 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -76,6 +76,7 @@ export type { ManifestAssetLink, } from './manifest' export { + createInlineCssStyleAsset, buildMetaTags, dedupByLastKey, getAssetCrossOrigin, From 14865ab70f9c70b12dbc9b67ea56d7f10480ff42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3mulo=20Ochoa=20Valez?= Date: Sat, 2 May 2026 09:04:33 +0200 Subject: [PATCH 13/17] Fix missing nonce on script:ld+json, don't remove nonce from meta as suggested to not break retrocompatibility and because although is not so useful is still valid (nonce is a global attribute) --- packages/router-core/src/manifest.ts | 2 +- packages/router-core/tests/head-dedup.test.ts | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/manifest.ts b/packages/router-core/src/manifest.ts index 4729971db59..084089018c7 100644 --- a/packages/router-core/src/manifest.ts +++ b/packages/router-core/src/manifest.ts @@ -194,7 +194,7 @@ export function buildMetaTags( const json = JSON.stringify(m['script:ld+json']) resultMeta.push({ tag: 'script', - attrs: { type: 'application/ld+json' }, + attrs: { type: 'application/ld+json', nonce }, children: escapeHtml(json), key: m.key, }) diff --git a/packages/router-core/tests/head-dedup.test.ts b/packages/router-core/tests/head-dedup.test.ts index 2b7eb80e27d..8d8bcd48a8f 100644 --- a/packages/router-core/tests/head-dedup.test.ts +++ b/packages/router-core/tests/head-dedup.test.ts @@ -8,7 +8,9 @@ describe('dedupByLastKey', () => { { key: 'canonical', href: '/second' }, { key: 'canonical', href: '/third' }, ] - expect(dedupByLastKey(items)).toEqual([{ key: 'canonical', href: '/third' }]) + expect(dedupByLastKey(items)).toEqual([ + { key: 'canonical', href: '/third' }, + ]) }) it('preserves keyless entries while deduplicating keyed ones', () => { @@ -153,7 +155,12 @@ describe('buildMetaTags', () => { it('appends a csp-nonce meta tag and propagates the nonce to meta attrs', () => { const tags = buildMetaTags( - [[{ name: 'description', content: 'hello' }]], + [ + [ + { name: 'description', content: 'hello' }, + { 'script:ld+json': { '@context': 'https://schema.org' } }, + ], + ], 'abc123', ) @@ -162,6 +169,12 @@ describe('buildMetaTags', () => { )! expect(descMeta.attrs).toMatchObject({ nonce: 'abc123' }) + const ldScript = tags.find((t) => t.tag === 'script')! + expect(ldScript.attrs).toMatchObject({ + type: 'application/ld+json', + nonce: 'abc123', + }) + const cspMeta = tags.find( (t) => t.tag === 'meta' && t.attrs?.property === 'csp-nonce', )! From 0a1076773b9a43eefda6464ac2f5bf2eb76de367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3mulo=20Ochoa=20Valez?= Date: Sat, 2 May 2026 09:07:46 +0200 Subject: [PATCH 14/17] Fix missing nonce attributes on vue router --- packages/vue-router/src/headContentUtils.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index dfff462fac1..e9b9b2f3f62 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -18,9 +18,13 @@ import type { export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() const matches = useStore(router.stores.matches, (value) => value) + const nonce = router.options.ssr?.nonce const meta = Vue.computed>(() => - buildMetaTags(matches.value.map((match) => match.meta!).filter(Boolean)), + buildMetaTags( + matches.value.map((match) => match.meta!).filter(Boolean), + nonce, + ), ) const links = Vue.computed>( @@ -36,6 +40,7 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { tag: 'link', attrs: { ...attrs, + nonce, }, key, } @@ -60,6 +65,7 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { crossOrigin: getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ?? preloadLink.crossOrigin, + nonce, }, }) }), @@ -78,6 +84,7 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { tag: 'script', attrs: { ...script, + nonce, }, children, key, @@ -105,6 +112,7 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { crossOrigin: getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? asset.attrs?.crossOrigin, + nonce, }, }, ] @@ -114,7 +122,10 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { return [ { tag: 'style', - attrs: asset.attrs, + attrs: { + ...asset.attrs, + nonce, + }, children: asset.children, ...(asset.inlineCss ? { inlineCss: true as const } : {}), }, From 569bec9c7f7e71b51fd1e96706ae33cdda5b606d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3mulo=20Ochoa=20Valez?= Date: Sat, 2 May 2026 11:39:59 +0200 Subject: [PATCH 15/17] Improve doc examples and tests clarity for deferred fallbacks --- docs/router/guide/document-head-management.md | 10 +++--- .../tests/deferred-head-loading.test.ts | 34 +++++++++---------- packages/router-core/tests/head-dedup.test.ts | 34 +++++++++---------- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index c32737651e7..32248d67071 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -248,15 +248,17 @@ export const Route = createFileRoute('/product/$slug')({ meta: [ // title and name/property meta dedupe automatically — no key needed { title: 'Loading...' }, + { name: 'theme-color', content: '#00b8db' }, loaderData.dataPromise.then((data) => [ { title: data.title }, + { name: 'theme-color', content: data.brandColor }, ]), ], links: [ - // Use key so the fallback canonical is replaced by the resolved one - { rel: 'canonical', href: '/fallback-url', key: 'canonical' }, + // Use key so the fallback favicon is replaced by the resolved one + { rel: 'icon', href: '/favicon.ico', key: 'icon' }, loaderData.dataPromise.then((data) => [ - { rel: 'canonical', href: data.canonicalUrl, key: 'canonical' }, + { rel: 'icon', href: data.iconUrl, key: 'icon' }, ]), ], }), @@ -266,7 +268,7 @@ export const Route = createFileRoute('/product/$slug')({ In this example: -1. **Before the promise resolves**, the page renders with the fallback `title` and the fallback canonical link +1. **Before the promise resolves**, the page renders with the fallback `title`, the fallback `theme-color`, and the fallback favicon link 2. **After the promise resolves**, `head()` is re-evaluated. The deferred entries now resolve immediately and appear after the fallback entries in the array. Since they share the same `key` (or `name`/`property`/`title`), deduplication keeps only the last occurrence — the resolved one ## Managing Body Scripts diff --git a/packages/router-core/tests/deferred-head-loading.test.ts b/packages/router-core/tests/deferred-head-loading.test.ts index 07e01397659..18ede028ce8 100644 --- a/packages/router-core/tests/deferred-head-loading.test.ts +++ b/packages/router-core/tests/deferred-head-loading.test.ts @@ -778,7 +778,7 @@ describe('rejected deferred head promises', () => { describe('deferred fallbacks: key-based replacement at render time', () => { it('keyed link fallback is replaced by the deferred resolution', async () => { - const dataPromise = createControlledPromise<{ canonical: string }>() + const dataPromise = createControlledPromise<{ iconUrl: string }>() const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ @@ -787,9 +787,9 @@ describe('deferred fallbacks: key-based replacement at render time', () => { loader: () => ({ dataPromise }), head: ({ loaderData }: { loaderData?: any }) => ({ links: [ - { rel: 'canonical', href: '/fallback', key: 'canonical' }, - loaderData?.dataPromise.then((d: { canonical: string }) => [ - { rel: 'canonical', href: d.canonical, key: 'canonical' }, + { rel: 'icon', href: '/favicon.ico', key: 'icon' }, + loaderData?.dataPromise.then((d: { iconUrl: string }) => [ + { rel: 'icon', href: d.iconUrl, key: 'icon' }, ]), ], }), @@ -806,28 +806,28 @@ describe('deferred fallbacks: key-based replacement at render time', () => { const matchId = router.state.matches.find((m) => m.routeId === '/')!.id let match = router.getMatch(matchId)! expect(match.links).toEqual([ - { rel: 'canonical', href: '/fallback', key: 'canonical' }, + { rel: 'icon', href: '/favicon.ico', key: 'icon' }, ]) expect(dedupByLastKey(match.links as any)).toEqual([ - { rel: 'canonical', href: '/fallback', key: 'canonical' }, + { rel: 'icon', href: '/favicon.ico', key: 'icon' }, ]) - dataPromise.resolve({ canonical: '/resolved' }) + dataPromise.resolve({ iconUrl: '/tenant-icon.png' }) await new Promise((r) => setTimeout(r, 10)) // Both entries are present in match data — render-time dedup picks the last. match = router.getMatch(matchId)! expect(match.links).toEqual([ - { rel: 'canonical', href: '/fallback', key: 'canonical' }, - { rel: 'canonical', href: '/resolved', key: 'canonical' }, + { rel: 'icon', href: '/favicon.ico', key: 'icon' }, + { rel: 'icon', href: '/tenant-icon.png', key: 'icon' }, ]) expect(dedupByLastKey(match.links as any)).toEqual([ - { rel: 'canonical', href: '/resolved', key: 'canonical' }, + { rel: 'icon', href: '/tenant-icon.png', key: 'icon' }, ]) }) it('without a key, fallback and resolved links both survive — demonstrates why the key exists', async () => { - const dataPromise = createControlledPromise<{ canonical: string }>() + const dataPromise = createControlledPromise<{ iconUrl: string }>() const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ @@ -836,9 +836,9 @@ describe('deferred fallbacks: key-based replacement at render time', () => { loader: () => ({ dataPromise }), head: ({ loaderData }: { loaderData?: any }) => ({ links: [ - { rel: 'canonical', href: '/fallback' }, - loaderData?.dataPromise.then((d: { canonical: string }) => [ - { rel: 'canonical', href: d.canonical }, + { rel: 'icon', href: '/favicon.ico' }, + loaderData?.dataPromise.then((d: { iconUrl: string }) => [ + { rel: 'icon', href: d.iconUrl }, ]), ], }), @@ -851,14 +851,14 @@ describe('deferred fallbacks: key-based replacement at render time', () => { }) await router.load() - dataPromise.resolve({ canonical: '/resolved' }) + dataPromise.resolve({ iconUrl: '/tenant-icon.png' }) await new Promise((r) => setTimeout(r, 10)) const match = router.state.matches.find((m) => m.routeId === '/')! // dedupByLastKey is a no-op without keys — both links survive. expect(dedupByLastKey(match.links as any)).toEqual([ - { rel: 'canonical', href: '/fallback' }, - { rel: 'canonical', href: '/resolved' }, + { rel: 'icon', href: '/favicon.ico' }, + { rel: 'icon', href: '/tenant-icon.png' }, ]) }) diff --git a/packages/router-core/tests/head-dedup.test.ts b/packages/router-core/tests/head-dedup.test.ts index 8d8bcd48a8f..96e42b887ba 100644 --- a/packages/router-core/tests/head-dedup.test.ts +++ b/packages/router-core/tests/head-dedup.test.ts @@ -4,39 +4,39 @@ import { buildMetaTags, dedupByLastKey } from '../src' describe('dedupByLastKey', () => { it('keeps the last occurrence of each keyed entry', () => { const items = [ - { key: 'canonical', href: '/first' }, - { key: 'canonical', href: '/second' }, - { key: 'canonical', href: '/third' }, + { key: 'icon', rel: 'icon', href: '/first.png' }, + { key: 'icon', rel: 'icon', href: '/second.png' }, + { key: 'icon', rel: 'icon', href: '/third.png' }, ] expect(dedupByLastKey(items)).toEqual([ - { key: 'canonical', href: '/third' }, + { key: 'icon', rel: 'icon', href: '/third.png' }, ]) }) it('preserves keyless entries while deduplicating keyed ones', () => { const items = [ - { href: '/favicon.ico' }, - { key: 'canonical', href: '/fallback' }, - { href: '/another-keyless' }, - { key: 'canonical', href: '/resolved' }, + { rel: 'manifest', href: '/manifest.json' }, + { key: 'icon', rel: 'icon', href: '/favicon.ico' }, + { rel: 'apple-touch-icon', href: '/touch-icon.png' }, + { key: 'icon', rel: 'icon', href: '/tenant-icon.png' }, ] expect(dedupByLastKey(items)).toEqual([ - { href: '/favicon.ico' }, - { href: '/another-keyless' }, - { key: 'canonical', href: '/resolved' }, + { rel: 'manifest', href: '/manifest.json' }, + { rel: 'apple-touch-icon', href: '/touch-icon.png' }, + { key: 'icon', rel: 'icon', href: '/tenant-icon.png' }, ]) }) it('deduplicates each key independently', () => { const items = [ - { key: 'canonical', href: '/canonical-old' }, - { key: 'alt-es', href: '/alt-old' }, - { key: 'canonical', href: '/canonical-new' }, - { key: 'alt-es', href: '/alt-new' }, + { key: 'icon', rel: 'icon', href: '/icon-old.png' }, + { key: 'apple-icon', rel: 'apple-touch-icon', href: '/apple-old.png' }, + { key: 'icon', rel: 'icon', href: '/icon-new.png' }, + { key: 'apple-icon', rel: 'apple-touch-icon', href: '/apple-new.png' }, ] expect(dedupByLastKey(items)).toEqual([ - { key: 'canonical', href: '/canonical-new' }, - { key: 'alt-es', href: '/alt-new' }, + { key: 'icon', rel: 'icon', href: '/icon-new.png' }, + { key: 'apple-icon', rel: 'apple-touch-icon', href: '/apple-new.png' }, ]) }) From 91a5a36d3b44083b6987f18705c96d5c2840a060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3mulo=20Ochoa=20Valez?= Date: Sat, 2 May 2026 12:02:51 +0200 Subject: [PATCH 16/17] Add styles to docs, and improve test consistency --- docs/router/guide/document-head-management.md | 2 +- packages/router-core/tests/deferred-head-loading.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index 32248d67071..e1c3bfdc1e9 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -7,7 +7,7 @@ Document head management is the process of managing the head, title, meta, link, - Automatic deduping of `title` and `meta` tags - Automatic loading/unloading of tags based on route visibility - A composable way to merge `title` and `meta` tags from nested routes -- Deferred loading of `title`, `meta`, `links`, and `scripts` tags without blocking the initial page render +- Deferred loading of `title`, `meta`, `links`, `styles` and `scripts` tags without blocking the initial page render For full-stack applications that use Start, and even for single-page applications that use TanStack Router, managing the document head is a crucial part of any application for the following reasons: diff --git a/packages/router-core/tests/deferred-head-loading.test.ts b/packages/router-core/tests/deferred-head-loading.test.ts index 18ede028ce8..eae9390c262 100644 --- a/packages/router-core/tests/deferred-head-loading.test.ts +++ b/packages/router-core/tests/deferred-head-loading.test.ts @@ -587,7 +587,7 @@ describe('hydrate: deferred head loading', () => { } it('does not block hydration on a still-pending deferred head promise', async () => { - const mockMatches = setupDehydrated({ dataPromise }) + setupDehydrated({ dataPromise }) let hydrationDone = false const hydratePromise = hydrate(mockRouter).then(() => { @@ -600,10 +600,10 @@ describe('hydrate: deferred head loading', () => { expect(hydrationDone).toBe(true) await hydratePromise - expect(mockMatches[0].meta).toEqual([ + expect(mockRouter.getMatch('/')!.meta).toEqual([ { name: 'static', content: 'present' }, ]) - expect(mockMatches[0].scripts).toEqual([ + expect(mockRouter.getMatch('/')!.scripts).toEqual([ { children: 'console.log("static")' }, ]) expect(headFn).toHaveBeenCalledTimes(1) From 90e4facc49d3b8aac3bde28221481bc1ebf2d5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3mulo=20Ochoa=20Valez?= Date: Sat, 2 May 2026 17:58:10 +0200 Subject: [PATCH 17/17] Improve deferred fallbacks example with a real world use case --- docs/router/guide/document-head-management.md | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index e1c3bfdc1e9..e5bfe3a09af 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -171,13 +171,13 @@ const rootRoute = createRootRoute({ ### Deferred Head Loading -When head data depends on an async source, awaiting it inside your loader blocks the entire page render — even though users don't need meta tags to interact with the page. To avoid that, you can return a **Promise** in any of `meta`, `links`, `scripts`, and `styles` from `head()`, and TanStack Router will: +When head data depends on an async source, awaiting it inside your loader blocks the entire page render, even though users don't need meta tags to interact with the page. To avoid that, you can return a **Promise** in any of `meta`, `links`, `scripts`, and `styles` from `head()`, and TanStack Router will: - Render the page immediately for users without blocking on the promise - Await the promise for crawlers so resolved tags appear in the initial response for correct indexing and social previews - Re-evaluate `head()` and `scripts()` on the client once the promise settles, so the resolved tags are committed via `` and `` without blocking navigation -To defer a tag, return the promise from your loader and pass it directly into any head array (or the body scripts array), alongside any static entries you already have. The promise can resolve to a single descriptor or an array of them — the router flattens the result into the surrounding array: +To defer a tag, return the promise from your loader and pass it directly into any head array (or the body scripts array), alongside any static entries you already have. The promise can resolve to a single descriptor or an array of them, and the router flattens the result into the surrounding array: ```tsx export const Route = createFileRoute('/product/$slug')({ @@ -188,10 +188,10 @@ export const Route = createFileRoute('/product/$slug')({ }, head: ({ loaderData }) => ({ meta: [ - // Static — present in the initial response + // Static (present in the initial response) { property: 'og:type', content: 'website' }, { name: 'twitter:site', content: '@mysite' }, - // Deferred — streamed for users, awaited for bots + // Deferred (streamed for users, awaited for bots) loaderData.dataPromise.then((data) => [ { title: data.title }, { name: 'description', content: data.description }, @@ -221,8 +221,8 @@ export const Route = createFileRoute('/product/$slug')({ ]), ], }), - // Body scripts can be deferred too — useful when the script's URL or - // payload depends on loader data, e.g. a tenant-specific analytics ID + // Body scripts can be deferred too. This is useful when the script's URL + // or payload depends on loader data, e.g. a tenant-specific analytics ID scripts: ({ loaderData }) => [ loaderData.dataPromise.then((data) => [ { src: `/analytics.js?id=${data.analyticsId}`, async: true }, @@ -234,42 +234,44 @@ export const Route = createFileRoute('/product/$slug')({ ### Deferred Fallbacks -When using deferred head loading, you may want to show fallback tags immediately while the deferred data is loading. For `title` and `meta` tags with `name` or `property`, this works automatically — the static entry is rendered first, and the deferred entry replaces it via built-in deduplication once the promise resolves. +When using deferred head loading, you may want to show fallback tags immediately while the deferred data is loading. For `title` and `meta` tags with `name` or `property`, this works automatically: the static entry is rendered first, and the deferred entry replaces it via built-in deduplication once the promise resolves. However, for **additive** tags like `links`, `scripts` and `styles` that don't have built-in deduplication attributes, the fallback and resolved entries would both appear in the document head. Use `key` to tie a fallback to its deferred replacement: ```tsx -export const Route = createFileRoute('/product/$slug')({ - loader: ({ params }) => { - const dataPromise = fetchPageData(params.slug) - return { dataPromise } +export const Route = createFileRoute('/inbox')({ + loader: () => { + const notificationsPromise = fetchUnreadNotifications() + return { notificationsPromise } }, head: ({ loaderData }) => ({ meta: [ - // title and name/property meta dedupe automatically — no key needed - { title: 'Loading...' }, - { name: 'theme-color', content: '#00b8db' }, - loaderData.dataPromise.then((data) => [ - { title: data.title }, - { name: 'theme-color', content: data.brandColor }, - ]), + // title dedupes automatically (no key needed) + { title: 'Inbox' }, + loaderData.notificationsPromise.then((n) => + n.unreadCount > 0 ? [{ title: `Inbox (${n.unreadCount} unread)` }] : [], + ), ], links: [ - // Use key so the fallback favicon is replaced by the resolved one + // Use key so the fallback favicon is replaced by the badged one { rel: 'icon', href: '/favicon.ico', key: 'icon' }, - loaderData.dataPromise.then((data) => [ - { rel: 'icon', href: data.iconUrl, key: 'icon' }, - ]), + loaderData.notificationsPromise.then((n) => + n.unreadCount > 0 + ? [{ rel: 'icon', href: `/favicon-badge-${n.unreadCount}.ico`, key: 'icon' }] + : [], + ), ], }), - component: ProductPage, + component: InboxPage, }) ``` In this example: -1. **Before the promise resolves**, the page renders with the fallback `title`, the fallback `theme-color`, and the fallback favicon link -2. **After the promise resolves**, `head()` is re-evaluated. The deferred entries now resolve immediately and appear after the fallback entries in the array. Since they share the same `key` (or `name`/`property`/`title`), deduplication keeps only the last occurrence — the resolved one +1. **Before the promise resolves**, the page renders with the default `Inbox` title and the default favicon, which are useful, neutral defaults that match what the user expects on first paint +2. **After the promise resolves**, `head()` is re-evaluated. The deferred entries now resolve immediately and appear after the fallback entries in the array. Since they share the same `key` (or `title`), deduplication keeps only the last occurrence (the resolved one), so the title gains the unread count and the favicon swaps for a badged variant + +Fallbacks work best when the default value is meaningful on its own and the deferred value enriches it (a count, a status hint, a personalized variant). For values that have no sensible default (like a tenant-specific `theme-color` or brand identity), it's fine to skip the fallback entirely and let the tag appear only once the deferred data resolves, rather than flashing a placeholder that's guaranteed to be wrong. ## Managing Body Scripts