diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 0e0aa726aad..407a18c71e9 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -81,6 +81,7 @@ "combinate": "^1.1.11", "react": "^19.0.0", "react-dom": "^19.0.0", + "unhead": "^2.0.8", "zod": "^3.24.2" }, "peerDependencies": { diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx index 638dee8acc3..d5aa1c784cb 100644 --- a/packages/react-router/src/HeadContent.tsx +++ b/packages/react-router/src/HeadContent.tsx @@ -4,6 +4,33 @@ import { useRouter } from './useRouter' import { useRouterState } from './useRouterState' import type { RouterManagedTag } from '@tanstack/router-core' +const isTruthy = (val?: string | boolean) => val === '' || val === true +const WEIGHT_MAP = { + meta: { + 'content-security-policy': -30, + charset: -20, + viewport: -15, + }, + link: { + preconnect: 20, + stylesheet: 60, + preload: 70, + modulepreload: 70, + prefetch: 90, + 'dns-prefetch': 90, + prerender: 90, + }, + script: { + async: 30, + defer: 80, + sync: 50, + }, + style: { + imported: 40, + sync: 60, + }, +} as const + export const useTags = () => { const router = useRouter() @@ -133,9 +160,61 @@ export const useTags = () => { */ export function HeadContent() { const tags = useTags() - return tags.map((tag) => ( - - )) + return tags + .map(weightTags) + .sort((a, b) => a.weight - b.weight) + .map((tag) => ) +} + +function weightTags(tag: RouterManagedTag) { + let weight = 100 + + if (tag.tag === 'title') { + weight = 10 + } else if (tag.tag === 'meta') { + const metaType = + tag.attrs?.httpEquiv === 'content-security-policy' + ? 'content-security-policy' + : tag.attrs?.charSet + ? 'charset' + : tag.attrs?.name === 'viewport' + ? 'viewport' + : null + + if (metaType) { + weight = WEIGHT_MAP.meta[metaType] + } + } else if (tag.tag === 'link' && tag.attrs?.rel) { + weight = + tag.attrs.rel in WEIGHT_MAP.link + ? WEIGHT_MAP.link[tag.attrs.rel as keyof typeof WEIGHT_MAP.link] + : weight + } else if (tag.tag === 'script') { + if (isTruthy(tag.attrs?.async)) { + weight = WEIGHT_MAP.script.async + } else if ( + tag.attrs?.src && + !isTruthy(tag.attrs.defer) && + !isTruthy(tag.attrs.async) && + tag.attrs.type !== 'module' && + !tag.attrs.type?.endsWith('json') + ) { + weight = WEIGHT_MAP.script.sync + } else if ( + isTruthy(tag.attrs?.defer) && + tag.attrs.src && + !isTruthy(tag.attrs.async) + ) { + weight = WEIGHT_MAP.script.defer + } + } else if (tag.tag === 'style') { + weight = tag.children ? WEIGHT_MAP.style.imported : WEIGHT_MAP.style.sync + } + + return { + ...tag, + weight, + } } function uniqBy(arr: Array, fn: (item: T) => string) { diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index d5a0638ad34..4b36df5acad 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -7,6 +7,7 @@ import { Transitioner } from './Transitioner' import { matchContext } from './matchContext' import { Match } from './Match' import { SafeFragment } from './SafeFragment' +import type { LinkWithoutEvents, Meta } from 'unhead/types' import type { StructuralSharingOption, ValidateSelected, @@ -31,8 +32,19 @@ import type { declare module '@tanstack/router-core' { export interface RouteMatchExtensions { - meta?: Array - links?: Array + meta?: Array< + | (React.JSX.IntrinsicElements['meta'] & + Omit & { + charSet?: Meta['charset'] + httpEquiv?: Meta['http-equiv'] + }) + | undefined + > + links?: Array< + | (React.JSX.IntrinsicElements['link'] & + Omit) + | undefined + > scripts?: Array headScripts?: Array } diff --git a/packages/router-core/package.json b/packages/router-core/package.json index 3daafd8a957..98adddae85c 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -62,5 +62,8 @@ "@tanstack/history": "workspace:*", "@tanstack/store": "^0.7.0", "tiny-invariant": "^1.3.3" + }, + "devDependencies": { + "unhead": "^2.0.8" } } diff --git a/packages/router-core/src/manifest.ts b/packages/router-core/src/manifest.ts index e789e00af47..0b2edfbc764 100644 --- a/packages/router-core/src/manifest.ts +++ b/packages/router-core/src/manifest.ts @@ -1,3 +1,5 @@ +import type { LinkWithoutEvents, Meta } from 'unhead/types' + export type Manifest = { routes: Record< string, @@ -16,8 +18,19 @@ export type RouterManagedTag = children: string } | { - tag: 'meta' | 'link' - attrs?: Record + tag: 'meta' + attrs?: + | (Record & + Omit & { + charSet?: Meta['charset'] + httpEquiv?: Meta['http-equiv'] + }) + | undefined + children?: never + } + | { + tag: 'link' + attrs?: Record & Omit children?: never } | { diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json index c0f8e510bf8..3b424bda4a8 100644 --- a/packages/solid-router/package.json +++ b/packages/solid-router/package.json @@ -87,6 +87,7 @@ "combinate": "^1.1.11", "eslint-plugin-solid": "^0.14.5", "solid-js": "^1.9.5", + "unhead": "^2.0.8", "vite-plugin-solid": "^2.11.2", "zod": "^3.23.8" }, diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx index 512903273af..cbb557fb63d 100644 --- a/packages/solid-router/src/HeadContent.tsx +++ b/packages/solid-router/src/HeadContent.tsx @@ -5,6 +5,33 @@ import { useRouter } from './useRouter' import { useRouterState } from './useRouterState' import type { RouterManagedTag } from '@tanstack/router-core' +const isTruthy = (val?: string | boolean) => val === '' || val === true +const WEIGHT_MAP = { + meta: { + 'content-security-policy': -30, + charset: -20, + viewport: -15, + }, + link: { + preconnect: 20, + stylesheet: 60, + preload: 70, + modulepreload: 70, + prefetch: 90, + 'dns-prefetch': 90, + prerender: 90, + }, + script: { + async: 30, + defer: 80, + sync: 50, + }, + style: { + imported: 40, + sync: 60, + }, +} as const + export const useTags = () => { const router = useRouter() @@ -132,15 +159,70 @@ export const useTags = () => { */ export function HeadContent() { const tags = useTags() + return ( - {tags().map((tag) => ( - - ))} + {tags() + .map(weightTags) + .sort((a, b) => a.weight - b.weight) + .map((tag) => ( + + ))} ) } +function weightTags(tag: RouterManagedTag) { + let weight = 100 + + if (tag.tag === 'title') { + weight = 10 + } else if (tag.tag === 'meta') { + const metaType = + tag.attrs?.httpEquiv === 'content-security-policy' + ? 'content-security-policy' + : tag.attrs?.charSet + ? 'charset' + : tag.attrs?.name === 'viewport' + ? 'viewport' + : null + + if (metaType) { + weight = WEIGHT_MAP.meta[metaType] + } + } else if (tag.tag === 'link' && tag.attrs?.rel) { + weight = + tag.attrs.rel in WEIGHT_MAP.link + ? WEIGHT_MAP.link[tag.attrs.rel as keyof typeof WEIGHT_MAP.link] + : weight + } else if (tag.tag === 'script') { + if (isTruthy(tag.attrs?.async)) { + weight = WEIGHT_MAP.script.async + } else if ( + tag.attrs?.src && + !isTruthy(tag.attrs.defer) && + !isTruthy(tag.attrs.async) && + tag.attrs.type !== 'module' && + !tag.attrs.type?.endsWith('json') + ) { + weight = WEIGHT_MAP.script.sync + } else if ( + isTruthy(tag.attrs?.defer) && + tag.attrs.src && + !isTruthy(tag.attrs.async) + ) { + weight = WEIGHT_MAP.script.defer + } + } else if (tag.tag === 'style') { + weight = tag.children ? WEIGHT_MAP.style.imported : WEIGHT_MAP.style.sync + } + + return { + ...tag, + weight, + } +} + function uniqBy(arr: Array, fn: (item: T) => string) { const seen = new Set() return arr.filter((item) => { diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index c269f74d68b..b87a8ecfc40 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -7,6 +7,7 @@ import { Transitioner } from './Transitioner' import { matchContext } from './matchContext' import { Match } from './Match' import { SafeFragment } from './SafeFragment' +import type { LinkWithoutEvents, Meta } from 'unhead/types' import type { AnyRouter, DeepPartial, @@ -26,8 +27,19 @@ import type { declare module '@tanstack/router-core' { export interface RouteMatchExtensions { - meta?: Array - links?: Array + meta?: Array< + | (Solid.JSX.IntrinsicElements['meta'] & + Omit & { + charSet?: Meta['charset'] + httpEquiv?: Meta['http-equiv'] + }) + | undefined + > + links?: Array< + | (Solid.JSX.IntrinsicElements['link'] & + Omit) + | undefined + > scripts?: Array headScripts?: Array } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d8aa651f17..d32bf9902f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5795,6 +5795,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + unhead: + specifier: ^2.0.8 + version: 2.0.8 zod: specifier: ^3.24.2 version: 3.24.2 @@ -6120,6 +6123,10 @@ importers: tiny-invariant: specifier: ^1.3.3 version: 1.3.3 + devDependencies: + unhead: + specifier: ^2.0.8 + version: 2.0.8 packages/router-devtools: dependencies: @@ -6389,6 +6396,9 @@ importers: solid-js: specifier: ^1.9.5 version: 1.9.5 + unhead: + specifier: ^2.0.8 + version: 2.0.8 vite-plugin-solid: specifier: ^2.11.2 version: 2.11.6(@testing-library/jest-dom@6.6.3)(solid-js@1.9.5)(vite@6.1.4(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) @@ -11031,6 +11041,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.14.1: + resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -13503,6 +13518,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} @@ -14845,6 +14861,9 @@ packages: ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + unc-path-regex@0.1.2: resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} engines: {node: '>=0.10.0'} @@ -14864,6 +14883,9 @@ packages: unenv@1.10.0: resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} + unhead@2.0.8: + resolution: {integrity: sha512-63WR+y08RZE7ChiFdgNY64haAkhCtUS5/HM7xo4Q83NA63txWbEh2WGmrKbArdQmSct+XlqbFN8ZL1yWpQEHEA==} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -15512,7 +15534,7 @@ snapshots: '@babel/types': 7.26.8 '@types/gensync': 1.0.4 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -15707,7 +15729,7 @@ snapshots: '@babel/parser': 7.26.8 '@babel/template': 7.26.8 '@babel/types': 7.26.8 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -16391,7 +16413,7 @@ snapshots: '@eslint/config-array@0.19.2': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -16405,7 +16427,7 @@ snapshots: '@eslint/eslintrc@3.3.0': dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 @@ -19346,7 +19368,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.23.0 '@typescript-eslint/visitor-keys': 8.23.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -19712,8 +19734,14 @@ snapshots: dependencies: acorn: 8.14.0 + acorn-jsx@5.3.2(acorn@8.14.1): + dependencies: + acorn: 8.14.1 + acorn@8.14.0: {} + acorn@8.14.1: {} + agent-base@6.0.2: dependencies: debug: 4.4.0(supports-color@9.4.0) @@ -20390,6 +20418,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.0: + dependencies: + ms: 2.1.3 + debug@4.4.0(supports-color@9.4.0): dependencies: ms: 2.1.3 @@ -20974,7 +21006,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 escape-string-regexp: 4.0.0 eslint-scope: 8.3.0 eslint-visitor-keys: 4.2.0 @@ -21000,8 +21032,8 @@ snapshots: espree@10.3.0: dependencies: - acorn: 8.14.0 - acorn-jsx: 5.3.2(acorn@8.14.0) + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) eslint-visitor-keys: 4.2.0 espree@9.6.1: @@ -22027,7 +22059,7 @@ snapshots: node-forge: 1.3.1 pathe: 1.1.2 std-env: 3.8.0 - ufo: 1.5.4 + ufo: 1.6.1 untun: 0.1.3 uqr: 0.1.2 @@ -22244,10 +22276,10 @@ snapshots: mlly@1.7.4: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 pathe: 2.0.3 pkg-types: 1.3.1 - ufo: 1.5.4 + ufo: 1.6.1 motion-dom@11.18.1: dependencies: @@ -22527,7 +22559,7 @@ snapshots: pathe: 2.0.3 pkg-types: 1.3.1 tinyexec: 0.3.2 - ufo: 1.5.4 + ufo: 1.6.1 object-assign@4.1.1: {} @@ -23671,7 +23703,7 @@ snapshots: terser@5.37.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 + acorn: 8.14.1 commander: 2.20.3 source-map-support: 0.5.21 @@ -23852,6 +23884,8 @@ snapshots: ufo@1.5.4: {} + ufo@1.6.1: {} + unc-path-regex@0.1.2: {} uncrypto@0.1.3: {} @@ -23875,6 +23909,10 @@ snapshots: node-fetch-native: 1.6.6 pathe: 1.1.2 + unhead@2.0.8: + dependencies: + hookable: 5.5.3 + unicode-emoji-modifier-base@1.0.0: {} unicorn-magic@0.1.0: {} @@ -23915,7 +23953,7 @@ snapshots: unplugin@1.16.1: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 webpack-virtual-modules: 0.6.2 unplugin@2.1.2: