Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
Expand Down
46 changes: 46 additions & 0 deletions packages/react-router/src/HeadContent.dev.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Asset {...tag} key={`tsr-meta-${JSON.stringify(tag)}`} nonce={nonce} />
))}
</>
)
}
246 changes: 1 addition & 245 deletions packages/react-router/src/HeadContent.tsx
Original file line number Diff line number Diff line change
@@ -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<RouterManagedTag> = React.useMemo(() => {
const resultMeta: Array<RouterManagedTag> = []
const metaByAttribute: Record<string, true> = {}
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<RouterManagedTag>

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<RouterManagedTag> = []

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<RouterManagedTag>
).map(({ children, ...attrs }) => ({
tag: 'style',
attrs,
children,
nonce,
})),
structuralSharing: true as any,
})

const headScripts: Array<RouterManagedTag> = useRouterState({
select: (state) =>
(
state.matches
.map((match) => match.headScripts!)
.flat(1)
.filter(Boolean) as Array<RouterManagedTag>
).map(({ children, ...script }) => ({
tag: 'script',
attrs: {
...script,
nonce,
},
children,
})),
structuralSharing: true as any,
})

return uniqBy(
[
...meta,
...preloadLinks,
...links,
...styles,
...headScripts,
] as Array<RouterManagedTag>,
(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 (
<link
rel="stylesheet"
href={href}
data-tanstack-start-dev-styles
suppressHydrationWarning
/>
)
}
import { useTags } from './headContentUtils'

/**
* Render route-managed head tags (title, meta, links, styles, head scripts).
Expand All @@ -245,22 +14,9 @@ export function HeadContent() {
const nonce = router.options.ssr?.nonce
return (
<>
{process.env.NODE_ENV !== 'production' && <DevStylesLink />}
{tags.map((tag) => (
<Asset {...tag} key={`tsr-meta-${JSON.stringify(tag)}`} nonce={nonce} />
))}
</>
)
}

function uniqBy<T>(arr: Array<T>, fn: (item: T) => string) {
const seen = new Set<string>()
return arr.filter((item) => {
const key = fn(item)
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
Loading
Loading