Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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