diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 1964130edf0..1c47e42a427 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -50,10 +50,12 @@ ".": { "import": { "types": "./dist/esm/index.d.ts", + "development": "./dist/esm/index.dev.js", "default": "./dist/esm/index.js" }, "require": { "types": "./dist/cjs/index.d.cts", + "development": "./dist/cjs/index.dev.cjs", "default": "./dist/cjs/index.cjs" } }, diff --git a/packages/react-router/src/HeadContent.dev.tsx b/packages/react-router/src/HeadContent.dev.tsx new file mode 100644 index 00000000000..3dfaa6613e0 --- /dev/null +++ b/packages/react-router/src/HeadContent.dev.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { Asset } from './Asset' +import { useRouter } from './useRouter' +import { useHydrated } from './ClientOnly' +import { useTags } from './headContentUtils' + +const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' + +/** + * Render route-managed head tags (title, meta, links, styles, head scripts). + * Place inside the document head of your app shell. + * + * Development version: filters out dev styles link after hydration and + * includes a fallback cleanup effect for hydration mismatch cases. + * + * @link https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management + */ +export function HeadContent() { + const tags = useTags() + const router = useRouter() + const nonce = router.options.ssr?.nonce + const hydrated = useHydrated() + + // Fallback cleanup for hydration mismatch cases + // Runs when hydration completes to remove any orphaned dev styles links from DOM + React.useEffect(() => { + if (hydrated) { + document + .querySelectorAll(`link[${DEV_STYLES_ATTR}]`) + .forEach((el) => el.remove()) + } + }, [hydrated]) + + // Filter out dev styles after hydration + const filteredTags = hydrated + ? tags.filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR]) + : tags + + return ( + <> + {filteredTags.map((tag) => ( + + ))} + + ) +} diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx index 026136c60d7..e917fd95964 100644 --- a/packages/react-router/src/HeadContent.tsx +++ b/packages/react-router/src/HeadContent.tsx @@ -1,238 +1,7 @@ import * as React from 'react' -import { buildDevStylesUrl, escapeHtml } from '@tanstack/router-core' import { Asset } from './Asset' import { useRouter } from './useRouter' -import { useRouterState } from './useRouterState' -import type { RouterManagedTag } from '@tanstack/router-core' - -/** - * Build the list of head/link/meta/script tags to render for active matches. - * Used internally by `HeadContent`. - */ -export const useTags = () => { - const router = useRouter() - const nonce = router.options.ssr?.nonce - const routeMeta = useRouterState({ - select: (state) => { - return state.matches.map((match) => match.meta!).filter(Boolean) - }, - }) - - 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 links = useRouterState({ - select: (state) => { - const constructed = state.matches - .map((match) => match.links!) - .filter(Boolean) - .flat(1) - .map((link) => ({ - tag: 'link', - attrs: { - ...link, - nonce, - }, - })) satisfies Array - - const manifest = router.ssr?.manifest - - // These are the assets extracted from the ViteManifest - // using the `startManifestPlugin` - const assets = state.matches - .map((match) => manifest?.routes[match.routeId]?.assets ?? []) - .filter(Boolean) - .flat(1) - .filter((asset) => asset.tag === 'link') - .map( - (asset) => - ({ - tag: 'link', - attrs: { - ...asset.attrs, - suppressHydrationWarning: true, - nonce, - }, - }) satisfies RouterManagedTag, - ) - - return [...constructed, ...assets] - }, - structuralSharing: true as any, - }) - - const preloadLinks = useRouterState({ - select: (state) => { - const preloadLinks: Array = [] - - state.matches - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => - router.ssr?.manifest?.routes[route.id]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - preloadLinks.push({ - tag: 'link', - attrs: { - rel: 'modulepreload', - href: preload, - nonce, - }, - }) - }), - ) - - return preloadLinks - }, - structuralSharing: true as any, - }) - - const styles = useRouterState({ - select: (state) => - ( - state.matches - .map((match) => match.styles!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...attrs }) => ({ - tag: 'style', - attrs, - children, - nonce, - })), - structuralSharing: true as any, - }) - - const headScripts: Array = useRouterState({ - select: (state) => - ( - state.matches - .map((match) => match.headScripts!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children, - })), - structuralSharing: true as any, - }) - - return uniqBy( - [ - ...meta, - ...preloadLinks, - ...links, - ...styles, - ...headScripts, - ] as Array, - (d) => { - return JSON.stringify(d) - }, - ) -} - -/** - * Renders a stylesheet link for dev mode CSS collection. - * On the server, renders the full link with route-scoped CSS URL. - * On the client, renders the same link to avoid hydration mismatch, - * then removes it after hydration since Vite's HMR handles CSS updates. - */ -function DevStylesLink() { - const router = useRouter() - const routeIds = useRouterState({ - select: (state) => state.matches.map((match) => match.routeId), - }) - - React.useEffect(() => { - // After hydration, remove the SSR-rendered dev styles link - document - .querySelectorAll('[data-tanstack-start-dev-styles]') - .forEach((el) => el.remove()) - }, []) - - const href = buildDevStylesUrl(router.basepath, routeIds) - - return ( - - ) -} +import { useTags } from './headContentUtils' /** * Render route-managed head tags (title, meta, links, styles, head scripts). @@ -245,22 +14,9 @@ export function HeadContent() { const nonce = router.options.ssr?.nonce return ( <> - {process.env.NODE_ENV !== 'production' && } {tags.map((tag) => ( ))} ) } - -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/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx new file mode 100644 index 00000000000..1345eebb22d --- /dev/null +++ b/packages/react-router/src/headContentUtils.tsx @@ -0,0 +1,217 @@ +import * as React from 'react' +import { escapeHtml } from '@tanstack/router-core' +import { useRouter } from './useRouter' +import { useRouterState } from './useRouterState' +import type { RouterManagedTag } from '@tanstack/router-core' + +/** + * Build the list of head/link/meta/script tags to render for active matches. + * Used internally by `HeadContent`. + */ +export const useTags = () => { + const router = useRouter() + const nonce = router.options.ssr?.nonce + const routeMeta = useRouterState({ + select: (state) => { + return state.matches.map((match) => match.meta!).filter(Boolean) + }, + }) + + 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 links = useRouterState({ + select: (state) => { + const constructed = state.matches + .map((match) => match.links!) + .filter(Boolean) + .flat(1) + .map((link) => ({ + tag: 'link', + attrs: { + ...link, + nonce, + }, + })) satisfies Array + + const manifest = router.ssr?.manifest + + // These are the assets extracted from the ViteManifest + // using the `startManifestPlugin` + const assets = state.matches + .map((match) => manifest?.routes[match.routeId]?.assets ?? []) + .filter(Boolean) + .flat(1) + .filter((asset) => asset.tag === 'link') + .map( + (asset) => + ({ + tag: 'link', + attrs: { + ...asset.attrs, + suppressHydrationWarning: true, + nonce, + }, + }) satisfies RouterManagedTag, + ) + + return [...constructed, ...assets] + }, + structuralSharing: true as any, + }) + + const preloadLinks = useRouterState({ + select: (state) => { + const preloadLinks: Array = [] + + state.matches + .map((match) => router.looseRoutesById[match.routeId]!) + .forEach((route) => + router.ssr?.manifest?.routes[route.id]?.preloads + ?.filter(Boolean) + .forEach((preload) => { + preloadLinks.push({ + tag: 'link', + attrs: { + rel: 'modulepreload', + href: preload, + nonce, + }, + }) + }), + ) + + return preloadLinks + }, + structuralSharing: true as any, + }) + + const styles = useRouterState({ + select: (state) => + ( + state.matches + .map((match) => match.styles!) + .flat(1) + .filter(Boolean) as Array + ).map(({ children, ...attrs }) => ({ + tag: 'style', + attrs: { + ...attrs, + nonce, + }, + children, + })), + structuralSharing: true as any, + }) + + const headScripts: Array = useRouterState({ + select: (state) => + ( + state.matches + .map((match) => match.headScripts!) + .flat(1) + .filter(Boolean) as Array + ).map(({ children, ...script }) => ({ + tag: 'script', + attrs: { + ...script, + nonce, + }, + children, + })), + structuralSharing: true as any, + }) + + return uniqBy( + [ + ...meta, + ...preloadLinks, + ...links, + ...styles, + ...headScripts, + ] as Array, + (d) => { + return JSON.stringify(d) + }, + ) +} + +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/index.dev.tsx b/packages/react-router/src/index.dev.tsx new file mode 100644 index 00000000000..e7c962f3d69 --- /dev/null +++ b/packages/react-router/src/index.dev.tsx @@ -0,0 +1,6 @@ +// Development entry point - re-exports everything from index.tsx +// but overrides HeadContent with the dev version that handles +// dev styles cleanup after hydration + +export * from './index' +export { HeadContent } from './HeadContent.dev' diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 9779e391b38..261bd099953 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -343,6 +343,7 @@ export type { export { ScriptOnce } from './ScriptOnce' export { Asset } from './Asset' export { HeadContent } from './HeadContent' +export { useTags } from './headContentUtils' export { Scripts } from './Scripts' export type * from './ssr/serializer' export { composeRewrites } from '@tanstack/router-core' diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index ddd11cf1a79..315b3e528e5 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -217,7 +217,7 @@ describe('ssr HeadContent', () => { , ) expect(html).toEqual( - `Index`, + `Index`, ) }) }) diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts index 5e60c3cd1c6..836afe7e03a 100644 --- a/packages/react-router/vite.config.ts +++ b/packages/react-router/vite.config.ts @@ -19,7 +19,12 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.tsx', './src/ssr/client.ts', './src/ssr/server.ts'], + entry: [ + './src/index.tsx', + './src/index.dev.tsx', + './src/ssr/client.ts', + './src/ssr/server.ts', + ], srcDir: './src', }), ) diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json index 00d7b52dbc3..4385840c330 100644 --- a/packages/solid-router/package.json +++ b/packages/solid-router/package.json @@ -48,14 +48,17 @@ ".": { "solid": { "types": "./dist/source/index.d.ts", + "development": "./dist/source/index.dev.jsx", "default": "./dist/source/index.jsx" }, "import": { "types": "./dist/esm/index.d.ts", + "development": "./dist/esm/index.dev.js", "default": "./dist/esm/index.js" }, "require": { "types": "./dist/cjs/index.d.cts", + "development": "./dist/cjs/index.dev.cjs", "default": "./dist/cjs/index.cjs" } }, diff --git a/packages/solid-router/src/HeadContent.dev.tsx b/packages/solid-router/src/HeadContent.dev.tsx new file mode 100644 index 00000000000..c29ad96eabe --- /dev/null +++ b/packages/solid-router/src/HeadContent.dev.tsx @@ -0,0 +1,45 @@ +import { MetaProvider } from '@solidjs/meta' +import { For, createEffect, createMemo } from 'solid-js' +import { Asset } from './Asset' +import { useHydrated } from './ClientOnly' +import { useTags } from './headContentUtils' + +const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' + +/** + * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. + * When using full document hydration (hydrating from ``), this component should be rendered in the `` + * to ensure it's part of the reactive tree and updates correctly during client-side navigation. + * The component uses portals internally to render content into the `` element. + * + * Development version: filters out dev styles link after hydration and + * includes a fallback cleanup effect for hydration mismatch cases. + */ +export function HeadContent() { + const tags = useTags() + const hydrated = useHydrated() + + // Fallback cleanup for hydration mismatch cases + // Runs when hydration completes to remove any orphaned dev styles links from DOM + createEffect(() => { + if (hydrated()) { + document + .querySelectorAll(`link[${DEV_STYLES_ATTR}]`) + .forEach((el) => el.remove()) + } + }) + + // Filter out dev styles after hydration + const filteredTags = createMemo(() => { + if (hydrated()) { + return tags().filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR]) + } + return tags() + }) + + return ( + + {(tag) => } + + ) +} diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx index e6cf3e44f86..8a02f146a70 100644 --- a/packages/solid-router/src/HeadContent.tsx +++ b/packages/solid-router/src/HeadContent.tsx @@ -1,225 +1,7 @@ -import * as Solid from 'solid-js' import { MetaProvider } from '@solidjs/meta' -import { For, Show, onMount } from 'solid-js' -import { buildDevStylesUrl, escapeHtml } from '@tanstack/router-core' +import { For } from 'solid-js' import { Asset } from './Asset' -import { useRouter } from './useRouter' -import { useRouterState } from './useRouterState' -import type { RouterManagedTag } from '@tanstack/router-core' - -export const useTags = () => { - const router = useRouter() - const nonce = router.options.ssr?.nonce - const routeMeta = useRouterState({ - select: (state) => { - return state.matches.map((match) => match.meta!).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 links = useRouterState({ - select: (state) => { - const constructed = state.matches - .map((match) => match.links!) - .filter(Boolean) - .flat(1) - .map((link) => ({ - tag: 'link', - attrs: { - ...link, - nonce, - }, - })) satisfies Array - - const manifest = router.ssr?.manifest - - // These are the assets extracted from the ViteManifest - // using the `startManifestPlugin` - const assets = state.matches - .map((match) => manifest?.routes[match.routeId]?.assets ?? []) - .filter(Boolean) - .flat(1) - .filter((asset) => asset.tag === 'link') - .map( - (asset) => - ({ - tag: 'link', - attrs: { ...asset.attrs, nonce }, - }) satisfies RouterManagedTag, - ) - - return [...constructed, ...assets] - }, - }) - - const preloadLinks = useRouterState({ - select: (state) => { - const preloadLinks: Array = [] - - state.matches - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => - router.ssr?.manifest?.routes[route.id]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - preloadLinks.push({ - tag: 'link', - attrs: { - rel: 'modulepreload', - href: preload, - nonce, - }, - }) - }), - ) - - return preloadLinks - }, - }) - - const styles = useRouterState({ - select: (state) => - ( - state.matches - .map((match) => match.styles!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...style }) => ({ - tag: 'style', - attrs: { - ...style, - nonce, - }, - children, - })), - }) - - const headScripts = useRouterState({ - select: (state) => - ( - state.matches - .map((match) => match.headScripts!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children, - })), - }) - - return () => - uniqBy( - [ - ...meta(), - ...preloadLinks(), - ...links(), - ...styles(), - ...headScripts(), - ] as Array, - (d) => { - return JSON.stringify(d) - }, - ) -} - -/** - * Renders a stylesheet link for dev mode CSS collection. - * On the server, renders the full link with route-scoped CSS URL. - * On the client, renders the same link to avoid hydration mismatch, - * then removes it after hydration since Vite's HMR handles CSS updates. - */ -function DevStylesLink() { - const router = useRouter() - const routeIds = useRouterState({ - select: (state) => state.matches.map((match) => match.routeId), - }) - - onMount(() => { - // After hydration, remove the SSR-rendered dev styles link - document - .querySelectorAll('[data-tanstack-start-dev-styles]') - .forEach((el) => el.remove()) - }) - - const href = () => buildDevStylesUrl(router.basepath, routeIds()) - - return -} +import { useTags } from './headContentUtils' /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. @@ -232,22 +14,7 @@ export function HeadContent() { return ( - - - {(tag) => } ) } - -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/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx new file mode 100644 index 00000000000..1aaf927afae --- /dev/null +++ b/packages/solid-router/src/headContentUtils.tsx @@ -0,0 +1,209 @@ +import * as Solid from 'solid-js' +import { escapeHtml } from '@tanstack/router-core' +import { useRouter } from './useRouter' +import { useRouterState } from './useRouterState' +import type { RouterManagedTag } from '@tanstack/router-core' + +/** + * Build the list of head/link/meta/script tags to render for active matches. + * Used internally by `HeadContent`. + */ +export const useTags = () => { + const router = useRouter() + const nonce = router.options.ssr?.nonce + const routeMeta = useRouterState({ + select: (state) => { + return state.matches.map((match) => match.meta!).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 links = useRouterState({ + select: (state) => { + const constructed = state.matches + .map((match) => match.links!) + .filter(Boolean) + .flat(1) + .map((link) => ({ + tag: 'link', + attrs: { + ...link, + nonce, + }, + })) satisfies Array + + const manifest = router.ssr?.manifest + + const assets = state.matches + .map((match) => manifest?.routes[match.routeId]?.assets ?? []) + .filter(Boolean) + .flat(1) + .filter((asset) => asset.tag === 'link') + .map( + (asset) => + ({ + tag: 'link', + attrs: { ...asset.attrs, nonce }, + }) satisfies RouterManagedTag, + ) + + return [...constructed, ...assets] + }, + }) + + const preloadLinks = useRouterState({ + select: (state) => { + const preloadLinks: Array = [] + + state.matches + .map((match) => router.looseRoutesById[match.routeId]!) + .forEach((route) => + router.ssr?.manifest?.routes[route.id]?.preloads + ?.filter(Boolean) + .forEach((preload) => { + preloadLinks.push({ + tag: 'link', + attrs: { + rel: 'modulepreload', + href: preload, + nonce, + }, + }) + }), + ) + + return preloadLinks + }, + }) + + const styles = useRouterState({ + select: (state) => + ( + state.matches + .map((match) => match.styles!) + .flat(1) + .filter(Boolean) as Array + ).map(({ children, ...style }) => ({ + tag: 'style', + attrs: { + ...style, + nonce, + }, + children, + })), + }) + + const headScripts = useRouterState({ + select: (state) => + ( + state.matches + .map((match) => match.headScripts!) + .flat(1) + .filter(Boolean) as Array + ).map(({ children, ...script }) => ({ + tag: 'script', + attrs: { + ...script, + nonce, + }, + children, + })), + }) + + return () => + uniqBy( + [ + ...meta(), + ...preloadLinks(), + ...links(), + ...styles(), + ...headScripts(), + ] as Array, + (d) => { + return JSON.stringify(d) + }, + ) +} + +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/index.dev.tsx b/packages/solid-router/src/index.dev.tsx new file mode 100644 index 00000000000..e7c962f3d69 --- /dev/null +++ b/packages/solid-router/src/index.dev.tsx @@ -0,0 +1,6 @@ +// Development entry point - re-exports everything from index.tsx +// but overrides HeadContent with the dev version that handles +// dev styles cleanup after hydration + +export * from './index' +export { HeadContent } from './HeadContent.dev' diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 6d0c31f2fce..929738e87ec 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -346,7 +346,8 @@ export type { export { ScriptOnce } from './ScriptOnce' export { Asset } from './Asset' -export { HeadContent, useTags } from './HeadContent' +export { HeadContent } from './HeadContent' +export { useTags } from './headContentUtils' export { Scripts } from './Scripts' export { composeRewrites } from '@tanstack/router-core' export type { diff --git a/packages/solid-router/src/ssr/RouterServer.tsx b/packages/solid-router/src/ssr/RouterServer.tsx index 99b4e684bbb..baa1c455029 100644 --- a/packages/solid-router/src/ssr/RouterServer.tsx +++ b/packages/solid-router/src/ssr/RouterServer.tsx @@ -7,7 +7,7 @@ import { } from 'solid-js/web' import { MetaProvider } from '@solidjs/meta' import { Asset } from '../Asset' -import { useTags } from '../HeadContent' +import { useTags } from '../headContentUtils' import { RouterProvider } from '../RouterProvider' import { Scripts } from '../Scripts' import type { AnyRouter } from '@tanstack/router-core' diff --git a/packages/solid-router/vite.config.ts b/packages/solid-router/vite.config.ts index 0d90e89b746..33d5d760415 100644 --- a/packages/solid-router/vite.config.ts +++ b/packages/solid-router/vite.config.ts @@ -36,7 +36,12 @@ export default defineConfig((env) => mergeConfig( config(env), tanstackViteConfig({ - entry: ['./src/index.tsx', './src/ssr/client.ts', './src/ssr/server.ts'], + entry: [ + './src/index.tsx', + './src/index.dev.tsx', + './src/ssr/client.ts', + './src/ssr/server.ts', + ], srcDir: './src', }), ), diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 85a673a4fa4..c35a24f3092 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -82,7 +82,12 @@ function getEntries() { return entriesPromise } -function getManifest() { +function getManifest(matchedRoutes?: ReadonlyArray) { + // In dev mode, always get fresh manifest (no caching) to include route-specific dev styles + if (process.env.TSS_DEV_SERVER === 'true') { + return getStartManifest(matchedRoutes) + } + // In prod, cache the manifest if (!manifestPromise) { manifestPromise = getStartManifest() } @@ -313,7 +318,10 @@ export function createStartHandler( } // Router execution function - const executeRouter = async (serverContext: TODO): Promise => { + const executeRouter = async ( + serverContext: TODO, + matchedRoutes?: ReadonlyArray, + ): Promise => { const acceptHeader = request.headers.get('Accept') || '*/*' const acceptParts = acceptHeader.split(',') const supportedMimeTypes = ['*/*', 'text/html'] @@ -329,7 +337,7 @@ export function createStartHandler( ) } - const manifest = await getManifest() + const manifest = await getManifest(matchedRoutes) const routerInstance = await getRouter() attachRouterServerSsrUtils({ @@ -477,7 +485,10 @@ async function handleServerRoutes({ getRouter: () => Promise request: Request url: URL - executeRouter: (serverContext: any) => Promise + executeRouter: ( + serverContext: any, + matchedRoutes?: ReadonlyArray, + ) => Promise context: any executedRequestMiddlewares: Set }): Promise { @@ -541,8 +552,10 @@ async function handleServerRoutes({ } } - // Final middleware: execute router - routeMiddlewares.push((ctx: TODO) => executeRouter(ctx.context)) + // Final middleware: execute router with matched routes for dev styles + routeMiddlewares.push((ctx: TODO) => + executeRouter(ctx.context, matchedRoutes), + ) const ctx = await executeMiddleware(routeMiddlewares, { request, diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index 8e6f268f977..6fe5e5c1a29 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -1,13 +1,21 @@ -import { rootRouteId } from '@tanstack/router-core' -import type { RouterManagedTag } from '@tanstack/router-core' +import { buildDevStylesUrl, rootRouteId } from '@tanstack/router-core' +import type { AnyRoute, RouterManagedTag } from '@tanstack/router-core' + +// Pre-computed constant for dev styles URL +const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' /** * @description Returns the router manifest that should be sent to the client. * This includes only the assets and preloads for the current route and any * special assets that are needed for the client. It does not include relationships * between routes or any other data that is not needed for the client. + * + * @param matchedRoutes - In dev mode, the matched routes are used to build + * the dev styles URL for route-scoped CSS collection. */ -export async function getStartManifest() { +export async function getStartManifest( + matchedRoutes?: ReadonlyArray, +) { const { tsrStartManifest } = await import('tanstack-start-manifest:v') const startManifest = tsrStartManifest() @@ -16,6 +24,19 @@ export async function getStartManifest() { rootRoute.assets = rootRoute.assets || [] + // Inject dev styles link in dev mode + if (process.env.TSS_DEV_SERVER === 'true' && matchedRoutes) { + const matchedRouteIds = matchedRoutes.map((route) => route.id) + rootRoute.assets.push({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href: buildDevStylesUrl(ROUTER_BASEPATH, matchedRouteIds), + 'data-tanstack-router-dev-styles': 'true', + }, + }) + } + let script = `import('${startManifest.clientEntry}')` if (process.env.TSS_DEV_SERVER === 'true') { const { injectedHeadScripts } = await import( diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json index b7a05120ec9..d2acf94918c 100644 --- a/packages/vue-router/package.json +++ b/packages/vue-router/package.json @@ -43,6 +43,7 @@ ".": { "import": { "types": "./dist/esm/index.d.ts", + "development": "./dist/esm/index.dev.js", "default": "./dist/esm/index.js" } }, diff --git a/packages/vue-router/src/HeadContent.dev.tsx b/packages/vue-router/src/HeadContent.dev.tsx new file mode 100644 index 00000000000..03a73557697 --- /dev/null +++ b/packages/vue-router/src/HeadContent.dev.tsx @@ -0,0 +1,42 @@ +import * as Vue from 'vue' + +import { Asset } from './Asset' +import { useHydrated } from './ClientOnly' +import { useTags } from './headContentUtils' + +const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' + +/** + * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. + * It should be rendered in the `` of your document. + * + * This is the development version that filters out dev styles after hydration. + */ +export const HeadContent = Vue.defineComponent({ + name: 'HeadContent', + setup() { + const tags = useTags() + const hydrated = useHydrated() + + // Fallback cleanup for hydration mismatch cases + Vue.onMounted(() => { + document + .querySelectorAll(`link[${DEV_STYLES_ATTR}]`) + .forEach((el) => el.remove()) + }) + + return () => { + // Filter out dev styles after hydration + const filteredTags = hydrated.value + ? tags().filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR]) + : tags() + + return filteredTags.map((tag) => + Vue.h(Asset, { + ...tag, + key: `tsr-meta-${JSON.stringify(tag)}`, + }), + ) + } + }, +}) diff --git a/packages/vue-router/src/HeadContent.tsx b/packages/vue-router/src/HeadContent.tsx index a022b77b376..0d2b41736ee 100644 --- a/packages/vue-router/src/HeadContent.tsx +++ b/packages/vue-router/src/HeadContent.tsx @@ -1,180 +1,7 @@ import * as Vue from 'vue' -import { buildDevStylesUrl, escapeHtml } from '@tanstack/router-core' import { Asset } from './Asset' -import { useRouter } from './useRouter' -import { useRouterState } from './useRouterState' -import type { RouterManagedTag } from '@tanstack/router-core' - -/** - * Renders a stylesheet link for dev mode CSS collection. - * On the server, renders the full link with route-scoped CSS URL. - * On the client, renders the same link to avoid hydration mismatch, - * then removes it after hydration since Vite's HMR handles CSS updates. - */ -const DevStylesLink = Vue.defineComponent({ - name: 'DevStylesLink', - setup() { - const router = useRouter() - const routeIds = useRouterState({ - select: (state) => state.matches.map((match) => match.routeId), - }) - - Vue.onMounted(() => { - // After hydration, remove the SSR-rendered dev styles link - document - .querySelectorAll('[data-tanstack-start-dev-styles]') - .forEach((el) => el.remove()) - }) - - const href = Vue.computed(() => - buildDevStylesUrl(router.basepath, routeIds.value), - ) - - return () => - Vue.h('link', { - rel: 'stylesheet', - href: href.value, - 'data-tanstack-start-dev-styles': true, - }) - }, -}) - -export const useTags = () => { - const router = useRouter() - - const routeMeta = useRouterState({ - select: (state) => { - return state.matches.map((match) => match.meta!).filter(Boolean) - }, - }) - - const meta: Vue.Ref> = Vue.computed(() => { - const resultMeta: Array = [] - const metaByAttribute: Record = {} - let title: RouterManagedTag | undefined - ;[...routeMeta.value].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 links = useRouterState({ - select: (state) => - state.matches - .map((match) => match.links!) - .filter(Boolean) - .flat(1) - .map((link) => ({ - tag: 'link', - attrs: { - ...link, - }, - })) as Array, - }) - - const preloadMeta = useRouterState({ - select: (state) => { - const preloadMeta: Array = [] - - state.matches - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => - router.ssr?.manifest?.routes[route.id]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - preloadMeta.push({ - tag: 'link', - attrs: { - rel: 'modulepreload', - href: preload, - }, - }) - }), - ) - - return preloadMeta - }, - }) - - const headScripts = useRouterState({ - select: (state) => - ( - state.matches - .map((match) => match.headScripts!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - }, - children, - })), - }) - - return () => - uniqBy( - [ - ...meta.value, - ...preloadMeta.value, - ...links.value, - ...headScripts.value, - ] as Array, - (d) => { - return JSON.stringify(d) - }, - ) -} +import { useTags } from './headContentUtils' /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. @@ -186,31 +13,12 @@ export const HeadContent = Vue.defineComponent({ const tags = useTags() return () => { - const children = tags().map((tag) => + return tags().map((tag) => Vue.h(Asset, { ...tag, key: `tsr-meta-${JSON.stringify(tag)}`, }), ) - - // In dev mode, prepend the DevStylesLink - if (process.env.NODE_ENV !== 'production') { - return [Vue.h(DevStylesLink), ...children] - } - - return children } }, }) - -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/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx new file mode 100644 index 00000000000..ae0fadbff1a --- /dev/null +++ b/packages/vue-router/src/headContentUtils.tsx @@ -0,0 +1,176 @@ +import * as Vue from 'vue' + +import { escapeHtml } from '@tanstack/router-core' +import { useRouter } from './useRouter' +import { useRouterState } from './useRouterState' +import type { RouterManagedTag } from '@tanstack/router-core' + +export const useTags = () => { + const router = useRouter() + + const routeMeta = useRouterState({ + select: (state) => { + return state.matches.map((match) => match.meta!).filter(Boolean) + }, + }) + + const meta: Vue.Ref> = Vue.computed(() => { + const resultMeta: Array = [] + const metaByAttribute: Record = {} + let title: RouterManagedTag | undefined + ;[...routeMeta.value].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 links = useRouterState({ + select: (state) => + state.matches + .map((match) => match.links!) + .filter(Boolean) + .flat(1) + .map((link) => ({ + tag: 'link', + attrs: { + ...link, + }, + })) as Array, + }) + + const preloadMeta = useRouterState({ + select: (state) => { + const preloadMeta: Array = [] + + state.matches + .map((match) => router.looseRoutesById[match.routeId]!) + .forEach((route) => + router.ssr?.manifest?.routes[route.id]?.preloads + ?.filter(Boolean) + .forEach((preload) => { + preloadMeta.push({ + tag: 'link', + attrs: { + rel: 'modulepreload', + href: preload, + }, + }) + }), + ) + + return preloadMeta + }, + }) + + const headScripts = useRouterState({ + select: (state) => + ( + state.matches + .map((match) => match.headScripts!) + .flat(1) + .filter(Boolean) as Array + ).map(({ children, ...script }) => ({ + tag: 'script', + attrs: { + ...script, + }, + children, + })), + }) + + const manifestAssets = useRouterState({ + select: (state) => { + const manifest = router.ssr?.manifest + + const assets = state.matches + .map((match) => manifest?.routes[match.routeId]?.assets ?? []) + .filter(Boolean) + .flat(1) + .filter((asset) => asset.tag === 'link') + .map( + (asset) => + ({ + tag: 'link', + attrs: { ...asset.attrs }, + }) satisfies RouterManagedTag, + ) + + return assets + }, + }) + + return () => + uniqBy( + [ + ...manifestAssets.value, + ...meta.value, + ...preloadMeta.value, + ...links.value, + ...headScripts.value, + ] as Array, + (d) => { + return JSON.stringify(d) + }, + ) +} + +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/index.dev.tsx b/packages/vue-router/src/index.dev.tsx new file mode 100644 index 00000000000..e7c962f3d69 --- /dev/null +++ b/packages/vue-router/src/index.dev.tsx @@ -0,0 +1,6 @@ +// Development entry point - re-exports everything from index.tsx +// but overrides HeadContent with the dev version that handles +// dev styles cleanup after hydration + +export * from './index' +export { HeadContent } from './HeadContent.dev' diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 3e6b1615161..3817956431b 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -339,6 +339,7 @@ export type { export { ScriptOnce } from './ScriptOnce' export { Asset } from './Asset' export { HeadContent } from './HeadContent' +export { useTags } from './headContentUtils' export { Scripts } from './Scripts' export { Body } from './Body' export { Html } from './Html' diff --git a/packages/vue-router/src/ssr/RouterServer.tsx b/packages/vue-router/src/ssr/RouterServer.tsx index a802165c4df..914461685c2 100644 --- a/packages/vue-router/src/ssr/RouterServer.tsx +++ b/packages/vue-router/src/ssr/RouterServer.tsx @@ -1,6 +1,6 @@ import * as Vue from 'vue' import { Asset } from '../Asset' -import { useTags } from '../HeadContent' +import { useTags } from '../headContentUtils' import { RouterProvider } from '../RouterProvider' import { Scripts } from '../Scripts' import type { AnyRouter, RouterManagedTag } from '@tanstack/router-core' diff --git a/packages/vue-router/vite.config.ts b/packages/vue-router/vite.config.ts index ac1212099b6..9985afd9f4a 100644 --- a/packages/vue-router/vite.config.ts +++ b/packages/vue-router/vite.config.ts @@ -24,7 +24,12 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.tsx', './src/ssr/client.ts', './src/ssr/server.ts'], + entry: [ + './src/index.tsx', + './src/index.dev.tsx', + './src/ssr/client.ts', + './src/ssr/server.ts', + ], srcDir: './src', cjs: false, }),