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..e5bfe3a09af 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`, `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:
@@ -168,6 +169,110 @@ 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:
+
+- 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, and 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. 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 },
+ ]),
+ ],
+ component: ProductPage,
+})
+```
+
+### 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.
+
+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('/inbox')({
+ loader: () => {
+ const notificationsPromise = fetchUnreadNotifications()
+ return { notificationsPromise }
+ },
+ head: ({ loaderData }) => ({
+ meta: [
+ // 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 badged one
+ { rel: 'icon', href: '/favicon.ico', key: 'icon' },
+ loaderData.notificationsPromise.then((n) =>
+ n.unreadCount > 0
+ ? [{ rel: 'icon', href: `/favicon-badge-${n.unreadCount}.ico`, key: 'icon' }]
+ : [],
+ ),
+ ],
+ }),
+ component: InboxPage,
+})
+```
+
+In this example:
+
+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
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..6c56fe932b0
--- /dev/null
+++ b/examples/react/start-deferred-head/src/routes/deferred.tsx
@@ -0,0 +1,130 @@
+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 can be deferred too — 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) => [
+ {
+ 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..8005f765418 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -31,11 +31,21 @@ import type {
declare module '@tanstack/router-core' {
export interface RouteMatchExtensions {
- meta?: Array
- links?: Array
- scripts?: Array
- styles?: Array
- headScripts?: Array
+ meta?: Array<
+ (React.JSX.IntrinsicElements['meta'] & { key?: string }) | undefined
+ >
+ links?: Array<
+ (React.JSX.IntrinsicElements['link'] & { key?: string }) | undefined
+ >
+ scripts?: Array<
+ (React.JSX.IntrinsicElements['script'] & { key?: string }) | undefined
+ >
+ styles?: Array<
+ (React.JSX.IntrinsicElements['style'] & { key?: string }) | undefined
+ >
+ headScripts?: Array<
+ (React.JSX.IntrinsicElements['script'] & { key?: string }) | undefined
+ >
}
}
diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx
index 2721bda40a9..8dc71d2a2b9 100644
--- a/packages/react-router/src/headContentUtils.tsx
+++ b/packages/react-router/src/headContentUtils.tsx
@@ -1,11 +1,14 @@
import * as React from 'react'
import { useStore } from '@tanstack/react-store'
import {
+ buildMetaTags,
+ dedupByLastKey,
deepEqual,
- escapeHtml,
getAssetCrossOrigin,
+ hashTag,
isInlinableStylesheet,
resolveManifestAssetLink,
+ uniqBy,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
@@ -20,84 +23,27 @@ function buildTagsFromMatches(
matches: Array,
assetCrossOrigin?: AssetCrossOriginConfig,
): Array {
- const routeMeta = matches.map((match) => match.meta!).filter(Boolean)
-
- const resultMeta: Array = []
- const metaByAttribute: Record = {}
- let title: RouterManagedTag | undefined
- for (let i = routeMeta.length - 1; i >= 0; i--) {
- const metas = routeMeta[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) {
- 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 (nonce) {
- resultMeta.push({
- tag: 'meta',
- attrs: {
- property: 'csp-nonce',
- content: nonce,
- },
- })
- }
- resultMeta.reverse()
+ const resultMeta = buildMetaTags(
+ matches.map((match) => match.meta!).filter(Boolean),
+ nonce,
+ )
- const constructedLinks = matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
+ const constructedLinks = 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
const assetLinks = matches
@@ -164,32 +110,34 @@ function buildTagsFromMatches(
}),
)
- const styles = (
+ const styles = dedupByLastKey(
matches
.map((match) => match.styles!)
.flat(1)
- .filter(Boolean) as Array
- ).map(({ children, ...attrs }) => ({
+ .filter(Boolean) as Array,
+ ).map(({ children, key, ...attrs }) => ({
tag: 'style',
attrs: {
...attrs,
nonce,
},
children,
+ key,
}))
- const headScripts = (
+ const headScripts = dedupByLastKey(
matches
.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,
}))
return uniqBy(
@@ -201,7 +149,7 @@ function buildTagsFromMatches(
...styles,
...headScripts,
] as Array,
- (d) => JSON.stringify(d),
+ hashTag,
)
}
@@ -232,92 +180,31 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
)
// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static
- const meta: Array = React.useMemo(() => {
- const resultMeta: Array = []
- const metaByAttribute: Record = {}
- let title: RouterManagedTag | undefined
- for (let i = routeMeta.length - 1; i >= 0; i--) {
- const metas = routeMeta[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 dangerouslySetInnerHTML
- 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 (nonce) {
- resultMeta.push({
- tag: 'meta',
- attrs: {
- property: 'csp-nonce',
- content: nonce,
- },
- })
- }
- resultMeta.reverse()
-
- return resultMeta
- }, [routeMeta, nonce])
+ const meta: Array = React.useMemo(
+ () => buildMetaTags(routeMeta, nonce),
+ [routeMeta, nonce],
+ )
// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static
const links = useStore(
router.stores.matches,
(matches) => {
- 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
@@ -406,18 +293,19 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
const styles = useStore(
router.stores.matches,
(matches) =>
- (
+ dedupByLastKey(
matches
.map((match) => match.styles!)
.flat(1)
- .filter(Boolean) as Array
- ).map(({ children, ...attrs }) => ({
+ .filter(Boolean) as Array,
+ ).map(({ children, key, ...attrs }) => ({
tag: 'style',
attrs: {
...attrs,
nonce,
},
children,
+ key,
})),
deepEqual,
)
@@ -426,18 +314,19 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
const headScripts: Array = useStore(
router.stores.matches,
(matches) =>
- (
+ dedupByLastKey(
matches
.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,
})),
deepEqual,
)
@@ -450,20 +339,6 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
...styles,
...headScripts,
] 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
- })
-}
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..7943056a93d 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
@@ -67,3 +70,291 @@ export function defer(
return promise
}
+
+interface DeferrableFields {
+ meta?: Array
+ links?: Array
+ headScripts?: Array
+ styles?: Array
+ scripts?: Array
+}
+
+interface ResolvedFields {
+ meta: Array | undefined
+ links: Array | undefined
+ headScripts: Array | undefined
+ styles: Array | undefined
+ scripts: Array | undefined
+}
+
+/**
+ * Resolve the deferred entries inside a route's `head()` and `scripts()` output
+ * for a single match.
+ *
+ * Returns the synchronous entries immediately. On the server the result is
+ * awaited only for bots; otherwise deferred promises are filtered out of the
+ * initial response and a re-evaluation pass is scheduled to update the match
+ * once they settle.
+ */
+export function resolveDeferredHead(
+ router: RouterCore,
+ matchId: string,
+ route: AnyRoute,
+ matches: Array,
+ headFnContent:
+ | { meta?: unknown; links?: unknown; scripts?: unknown; styles?: unknown }
+ | undefined,
+ scriptsRaw: unknown,
+ source: 'load' | 'hydrate',
+): ResolvedFields | Promise {
+ const metaArr = asArray(headFnContent?.meta)
+ const linksArr = asArray(headFnContent?.links)
+ const headScriptsArr = asArray(headFnContent?.scripts)
+ const stylesArr = asArray(headFnContent?.styles)
+ const scriptsArr = asArray(scriptsRaw)
+
+ // Fast path: skip resolve + re-eval. Each is a no-op when no field has a
+ // deferred entry, but they still allocate per match.
+ if (
+ !(metaArr && arrayHasPromise(metaArr)) &&
+ !(linksArr && arrayHasPromise(linksArr)) &&
+ !(headScriptsArr && arrayHasPromise(headScriptsArr)) &&
+ !(stylesArr && arrayHasPromise(stylesArr)) &&
+ !(scriptsArr && arrayHasPromise(scriptsArr))
+ ) {
+ return {
+ meta: metaArr,
+ links: linksArr,
+ headScripts: headScriptsArr,
+ styles: stylesArr,
+ scripts: scriptsArr,
+ }
+ }
+
+ // On non-bot SSR / client loads the deferred entries are filtered out of the
+ // initial result and only surface via the client-side re-eval pass. Server
+ // skips: the response is already in flight.
+ const fields: DeferrableFields = {
+ meta: metaArr,
+ links: linksArr,
+ headScripts: headScriptsArr,
+ styles: stylesArr,
+ scripts: scriptsArr,
+ }
+ if (!router.serverSsr) {
+ scheduleDeferredReEval(router, matchId, route, matches, fields, source)
+ }
+
+ return processAllDeferredFields(router, matchId, fields)
+}
+
+// Returns sync when no field actually went async, so the common path skips the
+// Promise.all over five wrappers per match.
+function processAllDeferredFields(
+ router: RouterCore,
+ matchId: string,
+ fields: DeferrableFields,
+ awaitClient?: boolean,
+): ResolvedFields | Promise {
+ const shouldAwait = router.serverSsr
+ ? !!router.serverSsr.isBot
+ : !!awaitClient
+
+ const meta = fields.meta
+ ? processDeferredArr(matchId, fields.meta, 'meta', shouldAwait)
+ : undefined
+ const links = fields.links
+ ? processDeferredArr(matchId, fields.links, 'links', shouldAwait)
+ : undefined
+ const headScripts = fields.headScripts
+ ? processDeferredArr(matchId, fields.headScripts, 'headScripts', shouldAwait)
+ : undefined
+ const styles = fields.styles
+ ? processDeferredArr(matchId, fields.styles, 'styles', shouldAwait)
+ : undefined
+ const scripts = fields.scripts
+ ? processDeferredArr(matchId, fields.scripts, 'scripts', shouldAwait)
+ : undefined
+
+ if (
+ !(meta instanceof Promise) &&
+ !(links instanceof Promise) &&
+ !(headScripts instanceof Promise) &&
+ !(styles instanceof Promise) &&
+ !(scripts instanceof Promise)
+ ) {
+ return { meta, links, headScripts, styles, scripts }
+ }
+
+ return Promise.all([meta, links, headScripts, styles, scripts]).then(
+ ([m, l, hs, st, sc]) => ({
+ meta: m,
+ links: l,
+ headScripts: hs,
+ styles: st,
+ scripts: sc,
+ }),
+ )
+}
+
+// Single-shot: any deferred promise produced by the second `head()` call is
+// awaited inline (awaitClient=true) instead of scheduling another pass, so a
+// `head()` that returns fresh promises on every call can't loop forever.
+function scheduleDeferredReEval(
+ router: RouterCore,
+ matchId: string,
+ route: AnyRoute,
+ matchesSnapshot: Array,
+ fields: DeferrableFields,
+ source: 'load' | 'hydrate',
+): void {
+ const promises: Array> = []
+ collectPromises(promises, fields.meta)
+ collectPromises(promises, fields.links)
+ collectPromises(promises, fields.headScripts)
+ collectPromises(promises, fields.styles)
+ collectPromises(promises, 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 headRaw = route.options.head?.(freshContext)
+ const scriptsRaw = route.options.scripts?.(freshContext)
+ const freshHead =
+ headRaw instanceof Promise ? await headRaw : headRaw
+ const freshScripts =
+ scriptsRaw instanceof Promise ? await scriptsRaw : scriptsRaw
+
+ const fields: DeferrableFields = {
+ meta: asArray(freshHead?.meta),
+ links: asArray(freshHead?.links),
+ headScripts: asArray(freshHead?.scripts),
+ styles: asArray(freshHead?.styles),
+ scripts: asArray(freshScripts),
+ }
+ const result = processAllDeferredFields(router, matchId, fields, true)
+ const resolved = result instanceof Promise ? await result : result
+ 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,
+ )
+ }
+ })
+}
+
+function processDeferredArr(
+ matchId: string,
+ arr: Array,
+ field: string,
+ shouldAwait: boolean,
+): Array | Promise> {
+ for (let i = 0; i < arr.length; i++) {
+ if (!(arr[i] instanceof Promise)) continue
+ return shouldAwait
+ ? resolveAsyncTail(matchId, arr, field, i)
+ : filterPromisesSync(matchId, arr, field, i)
+ }
+ return arr
+}
+
+function filterPromisesSync(
+ matchId: string,
+ arr: Array,
+ field: string,
+ start: number,
+): Array {
+ const result = arr.slice(0, start)
+ for (let i = start; i < arr.length; i++) {
+ const entry = arr[i]
+ if (!(entry instanceof Promise)) {
+ result.push(entry)
+ continue
+ }
+ // .catch silences the unhandled-rejection warning on entries we drop here;
+ // the re-eval pass picks up the resolved value from the next snapshot.
+ entry.catch((err) => {
+ console.error(
+ `Deferred ${field} promise rejected for "${matchId}|${field}|${i}":`,
+ err,
+ )
+ })
+ }
+ return result
+}
+
+async function resolveAsyncTail(
+ matchId: string,
+ arr: Array,
+ field: string,
+ start: number,
+): Promise> {
+ const result = arr.slice(0, start)
+ for (let i = start; i < arr.length; i++) {
+ const entry = arr[i]
+ if (!(entry instanceof Promise)) {
+ result.push(entry)
+ continue
+ }
+ try {
+ pushResolved(result, await entry)
+ } catch (err) {
+ // Drop the entry on rejection so one bad deferred doesn't poison the rest of the head.
+ console.error(
+ `Deferred ${field} promise rejected for "${matchId}|${field}|${i}":`,
+ err,
+ )
+ }
+ }
+ return result
+}
+
+function arrayHasPromise(arr: Array): boolean {
+ for (const e of arr) {
+ if (e instanceof Promise) return true
+ }
+ return false
+}
+
+function collectPromises(
+ out: Array>,
+ arr: Array | undefined,
+): void {
+ if (!arr) return
+ for (const e of arr) {
+ if (e instanceof Promise) out.push(e)
+ }
+}
+
+function asArray(v: unknown): Array | undefined {
+ return Array.isArray(v) ? v : undefined
+}
+
+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)
+ }
+}
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index ded15fff6df..76f593dc6e4 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -77,10 +77,14 @@ export type {
} from './manifest'
export {
createInlineCssStyleAsset,
+ buildMetaTags,
+ dedupByLastKey,
getAssetCrossOrigin,
getStylesheetHref,
+ hashTag,
isInlinableStylesheet,
resolveManifestAssetLink,
+ uniqBy,
} from './manifest'
export { isMatch } from './Matches'
export type {
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 275fd70c60d..76864a3c7f9 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -4,6 +4,7 @@ import { createControlledPromise, isPromise } from './utils'
import { isNotFound } from './not-found'
import { rootRouteId } from './root'
import { isRedirect } from './redirect'
+import { resolveDeferredHead } from './defer'
import type { NotFoundError } from './not-found'
import type { ParsedLocation } from './location'
import type {
@@ -559,16 +560,25 @@ const handleBeforeLoad = (
return serverSsr()
}
+type ExecuteHeadResult = Pick<
+ AnyRouteMatch,
+ 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles'
+>
+
+const attachHeaders = (
+ head: Awaited>,
+ headers: unknown,
+): ExecuteHeadResult => {
+ const result = head as ExecuteHeadResult
+ result.headers = headers as ExecuteHeadResult['headers']
+ return result
+}
+
const executeHead = (
inner: InnerLoadContext,
matchId: string,
route: AnyRoute,
-): void | Promise<
- Pick<
- AnyRouteMatch,
- 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles'
- >
-> => {
+): void | ExecuteHeadResult | Promise => {
const match = inner.router.getMatch(matchId)
// in case of a redirecting match during preload, the match does not exist
if (!match) {
@@ -585,25 +595,45 @@ const executeHead = (
loaderData: match.loaderData,
}
- return Promise.all([
- 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
+ const headRaw = route.options.head?.(assetContext)
+ const scriptsRaw = route.options.scripts?.(assetContext)
+ const headersRaw = route.options.headers?.(assetContext)
- return {
- meta,
- links,
- headScripts,
- headers,
- scripts,
- styles,
- }
- })
+ if (
+ !(headRaw instanceof Promise) &&
+ !(scriptsRaw instanceof Promise) &&
+ !(headersRaw instanceof Promise)
+ ) {
+ const head = resolveDeferredHead(
+ inner.router,
+ matchId,
+ route,
+ inner.matches,
+ headRaw,
+ scriptsRaw,
+ 'load',
+ )
+ return head instanceof Promise
+ ? head.then((h) => attachHeaders(h, headersRaw))
+ : attachHeaders(head, headersRaw)
+ }
+
+ return Promise.all([headRaw, scriptsRaw, headersRaw]).then(
+ ([headFnContent, scriptsResolved, headers]) => {
+ const head = resolveDeferredHead(
+ inner.router,
+ matchId,
+ route,
+ inner.matches,
+ headFnContent,
+ scriptsResolved,
+ 'load',
+ )
+ return head instanceof Promise
+ ? head.then((h) => attachHeaders(h, headers))
+ : attachHeaders(head, headers)
+ },
+ )
}
const getLoaderContext = (
@@ -1159,7 +1189,7 @@ export async function loadMatches(arg: {
try {
const headResult = executeHead(inner, matchId, route)
if (headResult) {
- const head = await headResult
+ const head = headResult instanceof Promise ? await headResult : headResult
inner.updateMatch(matchId, (prev) => ({
...prev,
...head,
diff --git a/packages/router-core/src/manifest.ts b/packages/router-core/src/manifest.ts
index e46c1428650..084089018c7 100644
--- a/packages/router-core/src/manifest.ts
+++ b/packages/router-core/src/manifest.ts
@@ -1,3 +1,5 @@
+import { escapeHtml } from './utils'
+
export type AssetCrossOrigin = 'anonymous' | 'use-credentials'
export type AssetCrossOriginConfig =
@@ -53,22 +55,26 @@ export type RouterManagedTag =
tag: 'title'
attrs?: Record
children: string
+ key?: string
}
| {
tag: 'meta' | 'link'
attrs?: Record
children?: never
+ key?: string
}
| {
tag: 'script'
attrs?: Record
children?: string
+ key?: string
}
| {
tag: 'style'
attrs?: Record
children?: string
inlineCss?: true
+ key?: string
}
export function getStylesheetHref(asset: RouterManagedTag) {
@@ -112,3 +118,116 @@ export function createInlineCssPlaceholderAsset(): RouterManagedTag {
inlineCss: true,
}
}
+
+export function uniqBy(arr: Array, fn: (item: T) => string): Array {
+ const seen = new Set()
+ return arr.filter((item) => {
+ const key = fn(item)
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+export function dedupByLastKey(
+ items: Array,
+): Array {
+ const lastIndexByKey: Record = {}
+ for (let i = 0; i < items.length; i++) {
+ const k = items[i]!.key
+ if (k) lastIndexByKey[k] = i
+ }
+ return items.filter((item, i) => {
+ if (item.key) return lastIndexByKey[item.key] === i
+ return true
+ })
+}
+
+function metaOutputTag(m: any): 'title' | 'script' | 'meta' {
+ if (m.title) return 'title'
+ if ('script:ld+json' in m) return 'script'
+ return 'meta'
+}
+
+export function hashTag({ key: _key, ...rest }: RouterManagedTag): string {
+ return JSON.stringify(rest)
+}
+
+export function buildMetaTags(
+ routeMeta: Array>,
+ nonce?: string,
+): Array {
+ const resultMeta: Array = []
+ const metaByAttribute: Record = {}
+ const metaByKey: Record = {}
+ let title: RouterManagedTag | undefined
+
+ for (let i = routeMeta.length - 1; i >= 0; i--) {
+ const metas = routeMeta[i]!
+ for (let j = metas.length - 1; j >= 0; j--) {
+ const m = metas[j]
+ if (!m) continue
+
+ // Key-based deduplication is scoped per output tag type — a ``,
+ // a ` ` and a `` with the same key all coexist.
+ // The mark is set only after a tag is actually committed so that entries
+ // which don't contribute a rendered tag — invalid JSON-LD, a parent's
+ // title that loses to a child's, etc. — don't reserve the key against
+ // later, valid entries.
+ const outputTag = metaOutputTag(m)
+ const dedupKey = m.key ? `${outputTag}:${m.key}` : null
+ if (dedupKey && metaByKey[dedupKey]) continue
+
+ if (m.title) {
+ if (!title) {
+ title = {
+ tag: 'title',
+ children: m.title,
+ key: m.key,
+ }
+ if (dedupKey) metaByKey[dedupKey] = true
+ }
+ } else if ('script:ld+json' in m) {
+ // JSON-LD content is HTML-escaped to prevent XSS when injected via
+ // innerHTML / dangerouslySetInnerHTML.
+ try {
+ const json = JSON.stringify(m['script:ld+json'])
+ resultMeta.push({
+ tag: 'script',
+ attrs: { type: 'application/ld+json', nonce },
+ children: escapeHtml(json),
+ key: m.key,
+ })
+ if (dedupKey) metaByKey[dedupKey] = true
+ } catch {
+ // Skip invalid JSON-LD objects
+ }
+ } else {
+ const attribute = m.name ?? m.property
+ if (attribute) {
+ if (metaByAttribute[attribute]) continue
+ metaByAttribute[attribute] = true
+ }
+ const { key, ...attrs } = m
+ resultMeta.push({
+ tag: 'meta',
+ attrs: { ...attrs, nonce },
+ key,
+ })
+ if (dedupKey) metaByKey[dedupKey] = true
+ }
+ }
+ }
+
+ if (title) resultMeta.push(title)
+
+ if (nonce) {
+ resultMeta.push({
+ tag: 'meta',
+ attrs: { property: 'csp-nonce', content: nonce },
+ })
+ }
+
+ resultMeta.reverse()
+ return resultMeta
+}
diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts
index b92401b652a..a0162bf9380 100644
--- a/packages/router-core/src/route.ts
+++ b/packages/router-core/src/route.ts
@@ -216,14 +216,18 @@ export type UpdatableStaticRouteOption = {} extends StaticDataRouteOption
: RequiredStaticDataRouteOption
export type MetaDescriptor =
- | { charSet: 'utf-8' }
- | { title: string }
- | { name: string; content: string }
- | { property: string; content: string }
- | { httpEquiv: string; content: string }
- | { 'script:ld+json': LdJsonObject }
- | { tagName: 'meta' | 'link'; [name: string]: string }
- | Record
+ | { charSet: 'utf-8'; key?: string }
+ | { title: string; key?: string }
+ | { name: string; content: string; key?: string }
+ | { property: string; content: string; key?: string }
+ | { httpEquiv: string; content: string; key?: string }
+ | { 'script:ld+json': LdJsonObject; key?: string }
+ | {
+ tagName: 'meta' | 'link'
+ key?: string
+ [name: string]: string | undefined
+ }
+ | (Record & { key?: string })
type LdJsonObject = { [Key in string]: LdJsonValue } & {
[Key in string]?: LdJsonValue | undefined
@@ -232,6 +236,11 @@ type LdJsonArray = Array | ReadonlyArray
type LdJsonPrimitive = string | number | boolean | null
type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray
+type DeferredHeadEntries =
+ T extends Array
+ ? Array | null | undefined>>
+ : T
+
export type RouteLinkEntry = {}
export type SearchValidator =
@@ -1371,10 +1380,10 @@ export interface UpdatableRouteOptions<
TLoaderDeps
>,
) => Awaitable<{
- links?: AnyRouteMatch['links']
- scripts?: AnyRouteMatch['headScripts']
- meta?: AnyRouteMatch['meta']
- styles?: AnyRouteMatch['styles']
+ links?: DeferredHeadEntries
+ scripts?: DeferredHeadEntries
+ meta?: DeferredHeadEntries
+ styles?: DeferredHeadEntries
}>
scripts?: (
ctx: AssetFnContextOptions<
@@ -1389,7 +1398,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..3a131978594 100644
--- a/packages/router-core/src/ssr/ssr-client.ts
+++ b/packages/router-core/src/ssr/ssr-client.ts
@@ -1,5 +1,6 @@
import { invariant } from '../invariant'
import { isNotFound } from '../not-found'
+import { resolveDeferredHead } from '../defer'
import { createControlledPromise } from '../utils'
import { hydrateSsrMatchId } from './ssr-match-id'
import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './constants'
@@ -218,15 +219,28 @@ export async function hydrate(router: AnyRouter): Promise {
params: match.params,
loaderData: match.loaderData,
}
- const headFnContent = await route.options.head?.(assetContext)
-
- const scripts = await route.options.scripts?.(assetContext)
-
- match.meta = headFnContent?.meta
- match.links = headFnContent?.links
- match.headScripts = headFnContent?.scripts
- match.styles = headFnContent?.styles
- match.scripts = scripts
+ const headRaw = route.options.head?.(assetContext)
+ const scriptsRaw = route.options.scripts?.(assetContext)
+ const headFnContent =
+ headRaw instanceof Promise ? await headRaw : headRaw
+ const scriptsResolved =
+ scriptsRaw instanceof Promise ? await scriptsRaw : scriptsRaw
+
+ const head = resolveDeferredHead(
+ router,
+ match.id,
+ route,
+ activeMatches,
+ headFnContent,
+ scriptsResolved,
+ 'hydrate',
+ )
+ const resolved = head instanceof Promise ? await head : head
+ match.meta = resolved.meta
+ match.links = resolved.links
+ match.headScripts = resolved.headScripts
+ match.styles = resolved.styles
+ match.scripts = resolved.scripts
} 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..eae9390c262
--- /dev/null
+++ b/packages/router-core/tests/deferred-head-loading.test.ts
@@ -0,0 +1,949 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createMemoryHistory } from '@tanstack/history'
+import {
+ BaseRootRoute,
+ BaseRoute,
+ buildMetaTags,
+ createControlledPromise,
+ dedupByLastKey,
+} from '../src'
+import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server'
+import { hydrate } from '../src/ssr/client'
+import { createTestRouter } from './routerTestUtils'
+import type { TsrSsrGlobal } from '../src/ssr/types'
+
+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' }]),
+ ],
+ }
+ }
+ 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
+
+ originalPromise.resolve({ title: 'unused' })
+ await new Promise((r) => setTimeout(r, 10))
+
+ expect(headFn).toHaveBeenCalledTimes(2)
+ expect(router.getMatch(indexMatchId)!.meta).toEqual([
+ { name: 'static', content: 'present' },
+ ])
+
+ 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")' }])
+ })
+})
+
+// `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 treats an absent/empty UA 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()
+ })
+})
+
+// Mirrors `executeHead`: first pass commits only static entries so hydration
+// is never blocked by a pending promise. A re-evaluation pass commits resolved
+// values through the store once deferred promises settle.
+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 () => {
+ 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
+
+ expect(mockRouter.getMatch('/')!.meta).toEqual([
+ { name: 'static', content: 'present' },
+ ])
+ expect(mockRouter.getMatch('/')!.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' },
+ { 'script:ld+json': { '@context': 'https://schema.org' } },
+ ],
+ ],
+ 'abc123',
+ )
+
+ const descMeta = tags.find(
+ (t) => t.tag === 'meta' && t.attrs?.name === 'description',
+ )!
+ 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',
+ )!
+ expect(cspMeta.attrs).toMatchObject({ content: 'abc123' })
+ })
+})
diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json
index 34d9925e5e5..71cfa493bd2 100644
--- a/packages/solid-router/package.json
+++ b/packages/solid-router/package.json
@@ -108,8 +108,7 @@
"@solid-primitives/refs": "^1.0.8",
"@solidjs/meta": "^0.29.4",
"@tanstack/history": "workspace:*",
- "@tanstack/router-core": "workspace:*",
- "isbot": "^5.1.22"
+ "@tanstack/router-core": "workspace:*"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.10",
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/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx
index 9f386d7d978..8b7dcd390e0 100644
--- a/packages/solid-router/src/ssr/renderRouterToStream.tsx
+++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx
@@ -1,5 +1,4 @@
import * as Solid from 'solid-js/web'
-import { isbot } from 'isbot'
import { transformReadableStreamWithRouter } from '@tanstack/router-core/ssr/server'
import { makeSsrSerovalPlugin } from '@tanstack/router-core'
import type { JSXElement } from 'solid-js'
@@ -42,7 +41,7 @@ export const renderRouterToStream = async ({
} as any,
)
- if (isbot(request.headers.get('User-Agent'))) {
+ if (router.serverSsr?.isBot) {
await stream
}
stream.pipeTo(writable)
diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
index a666ed87c11..3bd8c5553da 100644
--- a/packages/start-server-core/src/createStartHandler.ts
+++ b/packages/start-server-core/src/createStartHandler.ts
@@ -698,6 +698,7 @@ export function createStartHandler(
attachRouterServerSsrUtils({
router: routerInstance,
manifest,
+ request,
getRequestAssets: () =>
getStartContext({ throwIfNotFound: false })?.requestAssets,
includeUnmatchedRouteAssets: false,
diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json
index c90f0fcb4a7..c6b6d1389e0 100644
--- a/packages/vue-router/package.json
+++ b/packages/vue-router/package.json
@@ -77,7 +77,6 @@
"@tanstack/router-core": "workspace:*",
"@tanstack/vue-store": "^0.9.3",
"@vue/runtime-dom": "^3.5.25",
- "isbot": "^5.1.22",
"jsesc": "^3.0.2"
},
"devDependencies": {
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..e9b9b2f3f62 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'
@@ -15,80 +18,33 @@ 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>(() => {
- 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),
+ nonce,
+ ),
+ )
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,
+ nonce,
},
- })) as Array,
+ key,
+ }
+ }) as Array,
)
const preloadMeta = Vue.computed>(() => {
@@ -109,6 +65,7 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
crossOrigin:
getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ??
preloadLink.crossOrigin,
+ nonce,
},
})
}),
@@ -118,17 +75,19 @@ 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,
+ nonce,
},
children,
+ key,
})),
)
@@ -153,6 +112,7 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
crossOrigin:
getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ??
asset.attrs?.crossOrigin,
+ nonce,
},
},
]
@@ -162,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 } : {}),
},
@@ -184,20 +147,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
- })
-}
diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx
index 75c717128a5..7ae3d711179 100644
--- a/packages/vue-router/src/ssr/renderRouterToStream.tsx
+++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx
@@ -1,7 +1,6 @@
import { ReadableStream as NodeReadableStream } from 'node:stream/web'
import * as Vue from 'vue'
import { pipeToWebWritable, renderToString } from 'vue/server-renderer'
-import { isbot } from 'isbot'
import { transformReadableStreamWithRouter } from '@tanstack/router-core/ssr/server'
import type { AnyRouter } from '@tanstack/router-core'
import type { Component } from 'vue'
@@ -49,7 +48,7 @@ export const renderRouterToStream = async ({
}) => {
const app = Vue.createSSRApp(App, { router })
- if (isbot(request.headers.get('User-Agent'))) {
+ if (router.serverSsr?.isBot) {
let fullHtml = await renderToString(app)
const htmlOpenIndex = fullHtml.indexOf('