Skip to content
Open
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
21 changes: 18 additions & 3 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,24 @@ declare module '@tanstack/router-core' {
export interface RouteMatchExtensions {
meta?: Array<React.JSX.IntrinsicElements['meta'] | undefined>
links?: Array<React.JSX.IntrinsicElements['link'] | undefined>
scripts?: Array<React.JSX.IntrinsicElements['script'] | undefined>
styles?: Array<React.JSX.IntrinsicElements['style'] | undefined>
headScripts?: Array<React.JSX.IntrinsicElements['script'] | undefined>
scripts?: Array<
| (React.JSX.IntrinsicElements['script'] & {
children?: string | null | boolean | number | undefined
})
| undefined
>
styles?: Array<
| (React.JSX.IntrinsicElements['style'] & {
children?: string | null | boolean | number | undefined
})
| undefined
>
headScripts?: Array<
| (React.JSX.IntrinsicElements['script'] & {
children?: string | null | boolean | number | undefined
})
| undefined
>
}
}

Expand Down
287 changes: 167 additions & 120 deletions packages/react-router/src/headContentUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ export const useTags = () => {
const nonce = router.options.ssr?.nonce
const routeMeta = useRouterState({
select: (state) => {
return state.matches.map((match) => match.meta!).filter(Boolean)
const result = []
for (const match of state.matches) {
const meta = match.meta
if (!meta) continue
result.push(meta)
}
return result
},
structuralSharing: true as any,
})

const meta: Array<RouterManagedTag> = React.useMemo(() => {
const resultMeta: Array<RouterManagedTag> = []
const metaByAttribute: Record<string, true> = {}
// Process routeMeta into separate arrays for each tag type
const { title, ldJsonScripts, metaTags } = React.useMemo(() => {
const ldJsonScripts: Array<RouterManagedTag> = []
const seenLdJson = new Set<string>()
const metaTags: Array<RouterManagedTag> = []
const seenMeta = new Set<string>()
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--) {
Expand All @@ -38,8 +49,11 @@ export const useTags = () => {
// Handle JSON-LD structured data
// Content is HTML-escaped to prevent XSS when injected via dangerouslySetInnerHTML
try {
// Deduplicate by JSON content before creating the object
const json = JSON.stringify(m['script:ld+json'])
resultMeta.push({
if (seenLdJson.has(json)) continue
seenLdJson.add(json)
ldJsonScripts.push({
tag: 'script',
attrs: {
type: 'application/ld+json',
Expand All @@ -50,16 +64,14 @@ export const useTags = () => {
// Skip invalid JSON-LD objects
}
} else {
const attribute = m.name ?? m.property
if (attribute) {
if (metaByAttribute[attribute]) {
continue
} else {
metaByAttribute[attribute] = true
}
// Deduplicate
const key = `${m.name ?? m.property}\0${m.content}\0${m.media}`
if (key) {
if (seenMeta.has(key)) continue
seenMeta.add(key)
}

resultMeta.push({
metaTags.push({
tag: 'meta',
attrs: {
...m,
Expand All @@ -70,148 +82,183 @@ export const useTags = () => {
}
}

if (title) {
resultMeta.push(title)
}

if (nonce) {
resultMeta.push({
// Add CSP nonce meta tag if present
if (nonce && !seenMeta.has('csp-nonce')) {
metaTags.push({
tag: 'meta',
attrs: {
property: 'csp-nonce',
content: nonce,
},
})
}
resultMeta.reverse()

return resultMeta
// Reverse to restore original order (we iterated backwards for deduplication)
ldJsonScripts.reverse()
metaTags.reverse()

return { title, ldJsonScripts, metaTags }
}, [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 result: Array<RouterManagedTag> = []
const seen = new Set<string>()
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) =>
({
for (const match of state.matches) {
// Process constructed links from match.links
const matchLinks = match.links
if (matchLinks) {
for (const link of matchLinks) {
if (!link) continue
// Deduplicate
const key = `${link.rel}\0${link.href}\0${link.media}\0${link.type}\0${link.as}`
if (seen.has(key)) continue
seen.add(key)
result.push({
tag: 'link',
attrs: {
...link,
nonce,
},
})
}
}

// Process assets from manifest
const assets = manifest?.routes[match.routeId]?.assets
if (assets) {
for (const asset of assets) {
if (asset.tag !== 'link') continue
const attrs = asset.attrs
if (!attrs) continue
// Deduplicate
const key = `${attrs.rel}\0${attrs.href}\0${attrs.media}\0${attrs.type}\0${attrs.as}`
if (seen.has(key)) continue
seen.add(key)
result.push({
tag: 'link',
attrs: {
...asset.attrs,
...attrs,
suppressHydrationWarning: true,
nonce,
},
}) satisfies RouterManagedTag,
)
})
}
}
}

return [...constructed, ...assets]
return result
},
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
const result: Array<RouterManagedTag> = []
const seen = new Set<string>()
const manifest = router.ssr?.manifest

for (const match of state.matches) {
const route = router.looseRoutesById[match.routeId]
if (!route) continue

const preloads = manifest?.routes[route.id]?.preloads
if (!preloads) continue

for (const preload of preloads) {
if (!preload) continue
// Deduplicate by href before creating the object
if (seen.has(preload)) continue
seen.add(preload)
result.push({
tag: 'link',
attrs: {
rel: 'modulepreload',
href: preload,
nonce,
},
})
}
}

return result
},
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: {
...attrs,
nonce,
},
children,
})),
structuralSharing: true as any,
})
select: (state) => {
const result: Array<RouterManagedTag> = []
const seen = new Set<string>()

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,
})),
for (const match of state.matches) {
const matchStyles = match.styles
if (!matchStyles) continue

for (const style of matchStyles) {
if (!style) continue
// Deduplicate
const { children, ...attrs } = style
const key = `${attrs.media}\0${children}`
if (seen.has(key)) continue
seen.add(key)
result.push({
tag: 'style',
attrs: {
...attrs,
nonce,
},
children: children as string | undefined,
})
}
}

return result
},
structuralSharing: true as any,
})

return uniqBy(
[
...meta,
...preloadLinks,
...links,
...styles,
...headScripts,
] as Array<RouterManagedTag>,
(d) => {
return JSON.stringify(d)
},
)
}
const headScripts = useRouterState({
select: (state) => {
const result: Array<RouterManagedTag> = []
const seen = new Set<string>()

export 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
for (const match of state.matches) {
const matchScripts = match.headScripts
if (!matchScripts) continue

for (const script of matchScripts) {
if (!script) continue
// Deduplicate
const { children, ...attrs } = script
const key = `${attrs.src}\0${attrs.type}\0${children}`
if (seen.has(key)) continue
seen.add(key)
Comment on lines +232 to +238
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty deduplication key when both src and children are empty.

If a script has neither src nor children (or both are empty), the key becomes '', causing all such scripts to deduplicate against each other. Consider skipping deduplication for scripts without a meaningful key, or skip invalid scripts entirely.

🔧 Suggested fix
          const { children, ...attrs } = script
          const key = attrs.src ?? String(children ?? '')
+         // Only deduplicate if we have a meaningful key
+         if (key === '') continue // Skip scripts with no src or content
          if (seen.has(key)) continue
          seen.add(key)
🤖 Prompt for AI Agents
In `@packages/react-router/src/headContentUtils.tsx` around lines 231 - 237, The
dedupe key generation inside the loop over matchScripts (in
headContentUtils.tsx) can produce an empty string when both script.attrs.src and
script.children are missing/empty, causing unintended deduplication; update the
loop in the block that builds key (where { children, ...attrs } = script and key
= attrs.src ?? String(children ?? '')) to first derive a normalizedKey (trim
strings) and then skip processing (continue) if normalizedKey is empty — or
alternatively treat scripts with no meaningful key as non-dedupe candidates by
not checking/adding them to the seen set; adjust the logic around
seen.has(key)/seen.add(key) accordingly so only non-empty keys are deduplicated.

result.push({
tag: 'script',
attrs: {
...attrs,
nonce,
},
children: children as string | undefined,
})
}
}

return result
},
structuralSharing: true as any,
})

return [
...(title ? [title] : []),
...metaTags,
...ldJsonScripts,
...preloadLinks,
...links,
...styles,
...headScripts,
] as Array<RouterManagedTag>
}
Loading
Loading