Skip to content

Commit 99bb8d2

Browse files
fix: cleanly separate dev styles from prod runtime (#6356)
1 parent b1976d8 commit 99bb8d2

26 files changed

+832
-690
lines changed

packages/react-router/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@
5050
".": {
5151
"import": {
5252
"types": "./dist/esm/index.d.ts",
53+
"development": "./dist/esm/index.dev.js",
5354
"default": "./dist/esm/index.js"
5455
},
5556
"require": {
5657
"types": "./dist/cjs/index.d.cts",
58+
"development": "./dist/cjs/index.dev.cjs",
5759
"default": "./dist/cjs/index.cjs"
5860
}
5961
},
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from 'react'
2+
import { Asset } from './Asset'
3+
import { useRouter } from './useRouter'
4+
import { useHydrated } from './ClientOnly'
5+
import { useTags } from './headContentUtils'
6+
7+
const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles'
8+
9+
/**
10+
* Render route-managed head tags (title, meta, links, styles, head scripts).
11+
* Place inside the document head of your app shell.
12+
*
13+
* Development version: filters out dev styles link after hydration and
14+
* includes a fallback cleanup effect for hydration mismatch cases.
15+
*
16+
* @link https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management
17+
*/
18+
export function HeadContent() {
19+
const tags = useTags()
20+
const router = useRouter()
21+
const nonce = router.options.ssr?.nonce
22+
const hydrated = useHydrated()
23+
24+
// Fallback cleanup for hydration mismatch cases
25+
// Runs when hydration completes to remove any orphaned dev styles links from DOM
26+
React.useEffect(() => {
27+
if (hydrated) {
28+
document
29+
.querySelectorAll(`link[${DEV_STYLES_ATTR}]`)
30+
.forEach((el) => el.remove())
31+
}
32+
}, [hydrated])
33+
34+
// Filter out dev styles after hydration
35+
const filteredTags = hydrated
36+
? tags.filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR])
37+
: tags
38+
39+
return (
40+
<>
41+
{filteredTags.map((tag) => (
42+
<Asset {...tag} key={`tsr-meta-${JSON.stringify(tag)}`} nonce={nonce} />
43+
))}
44+
</>
45+
)
46+
}
Lines changed: 1 addition & 245 deletions
Original file line numberDiff line numberDiff line change
@@ -1,238 +1,7 @@
11
import * as React from 'react'
2-
import { buildDevStylesUrl, escapeHtml } from '@tanstack/router-core'
32
import { Asset } from './Asset'
43
import { useRouter } from './useRouter'
5-
import { useRouterState } from './useRouterState'
6-
import type { RouterManagedTag } from '@tanstack/router-core'
7-
8-
/**
9-
* Build the list of head/link/meta/script tags to render for active matches.
10-
* Used internally by `HeadContent`.
11-
*/
12-
export const useTags = () => {
13-
const router = useRouter()
14-
const nonce = router.options.ssr?.nonce
15-
const routeMeta = useRouterState({
16-
select: (state) => {
17-
return state.matches.map((match) => match.meta!).filter(Boolean)
18-
},
19-
})
20-
21-
const meta: Array<RouterManagedTag> = React.useMemo(() => {
22-
const resultMeta: Array<RouterManagedTag> = []
23-
const metaByAttribute: Record<string, true> = {}
24-
let title: RouterManagedTag | undefined
25-
for (let i = routeMeta.length - 1; i >= 0; i--) {
26-
const metas = routeMeta[i]!
27-
for (let j = metas.length - 1; j >= 0; j--) {
28-
const m = metas[j]
29-
if (!m) continue
30-
31-
if (m.title) {
32-
if (!title) {
33-
title = {
34-
tag: 'title',
35-
children: m.title,
36-
}
37-
}
38-
} else if ('script:ld+json' in m) {
39-
// Handle JSON-LD structured data
40-
// Content is HTML-escaped to prevent XSS when injected via dangerouslySetInnerHTML
41-
try {
42-
const json = JSON.stringify(m['script:ld+json'])
43-
resultMeta.push({
44-
tag: 'script',
45-
attrs: {
46-
type: 'application/ld+json',
47-
},
48-
children: escapeHtml(json),
49-
})
50-
} catch {
51-
// Skip invalid JSON-LD objects
52-
}
53-
} else {
54-
const attribute = m.name ?? m.property
55-
if (attribute) {
56-
if (metaByAttribute[attribute]) {
57-
continue
58-
} else {
59-
metaByAttribute[attribute] = true
60-
}
61-
}
62-
63-
resultMeta.push({
64-
tag: 'meta',
65-
attrs: {
66-
...m,
67-
nonce,
68-
},
69-
})
70-
}
71-
}
72-
}
73-
74-
if (title) {
75-
resultMeta.push(title)
76-
}
77-
78-
if (nonce) {
79-
resultMeta.push({
80-
tag: 'meta',
81-
attrs: {
82-
property: 'csp-nonce',
83-
content: nonce,
84-
},
85-
})
86-
}
87-
resultMeta.reverse()
88-
89-
return resultMeta
90-
}, [routeMeta, nonce])
91-
92-
const links = useRouterState({
93-
select: (state) => {
94-
const constructed = state.matches
95-
.map((match) => match.links!)
96-
.filter(Boolean)
97-
.flat(1)
98-
.map((link) => ({
99-
tag: 'link',
100-
attrs: {
101-
...link,
102-
nonce,
103-
},
104-
})) satisfies Array<RouterManagedTag>
105-
106-
const manifest = router.ssr?.manifest
107-
108-
// These are the assets extracted from the ViteManifest
109-
// using the `startManifestPlugin`
110-
const assets = state.matches
111-
.map((match) => manifest?.routes[match.routeId]?.assets ?? [])
112-
.filter(Boolean)
113-
.flat(1)
114-
.filter((asset) => asset.tag === 'link')
115-
.map(
116-
(asset) =>
117-
({
118-
tag: 'link',
119-
attrs: {
120-
...asset.attrs,
121-
suppressHydrationWarning: true,
122-
nonce,
123-
},
124-
}) satisfies RouterManagedTag,
125-
)
126-
127-
return [...constructed, ...assets]
128-
},
129-
structuralSharing: true as any,
130-
})
131-
132-
const preloadLinks = useRouterState({
133-
select: (state) => {
134-
const preloadLinks: Array<RouterManagedTag> = []
135-
136-
state.matches
137-
.map((match) => router.looseRoutesById[match.routeId]!)
138-
.forEach((route) =>
139-
router.ssr?.manifest?.routes[route.id]?.preloads
140-
?.filter(Boolean)
141-
.forEach((preload) => {
142-
preloadLinks.push({
143-
tag: 'link',
144-
attrs: {
145-
rel: 'modulepreload',
146-
href: preload,
147-
nonce,
148-
},
149-
})
150-
}),
151-
)
152-
153-
return preloadLinks
154-
},
155-
structuralSharing: true as any,
156-
})
157-
158-
const styles = useRouterState({
159-
select: (state) =>
160-
(
161-
state.matches
162-
.map((match) => match.styles!)
163-
.flat(1)
164-
.filter(Boolean) as Array<RouterManagedTag>
165-
).map(({ children, ...attrs }) => ({
166-
tag: 'style',
167-
attrs,
168-
children,
169-
nonce,
170-
})),
171-
structuralSharing: true as any,
172-
})
173-
174-
const headScripts: Array<RouterManagedTag> = useRouterState({
175-
select: (state) =>
176-
(
177-
state.matches
178-
.map((match) => match.headScripts!)
179-
.flat(1)
180-
.filter(Boolean) as Array<RouterManagedTag>
181-
).map(({ children, ...script }) => ({
182-
tag: 'script',
183-
attrs: {
184-
...script,
185-
nonce,
186-
},
187-
children,
188-
})),
189-
structuralSharing: true as any,
190-
})
191-
192-
return uniqBy(
193-
[
194-
...meta,
195-
...preloadLinks,
196-
...links,
197-
...styles,
198-
...headScripts,
199-
] as Array<RouterManagedTag>,
200-
(d) => {
201-
return JSON.stringify(d)
202-
},
203-
)
204-
}
205-
206-
/**
207-
* Renders a stylesheet link for dev mode CSS collection.
208-
* On the server, renders the full link with route-scoped CSS URL.
209-
* On the client, renders the same link to avoid hydration mismatch,
210-
* then removes it after hydration since Vite's HMR handles CSS updates.
211-
*/
212-
function DevStylesLink() {
213-
const router = useRouter()
214-
const routeIds = useRouterState({
215-
select: (state) => state.matches.map((match) => match.routeId),
216-
})
217-
218-
React.useEffect(() => {
219-
// After hydration, remove the SSR-rendered dev styles link
220-
document
221-
.querySelectorAll('[data-tanstack-start-dev-styles]')
222-
.forEach((el) => el.remove())
223-
}, [])
224-
225-
const href = buildDevStylesUrl(router.basepath, routeIds)
226-
227-
return (
228-
<link
229-
rel="stylesheet"
230-
href={href}
231-
data-tanstack-start-dev-styles
232-
suppressHydrationWarning
233-
/>
234-
)
235-
}
4+
import { useTags } from './headContentUtils'
2365

2376
/**
2387
* Render route-managed head tags (title, meta, links, styles, head scripts).
@@ -245,22 +14,9 @@ export function HeadContent() {
24514
const nonce = router.options.ssr?.nonce
24615
return (
24716
<>
248-
{process.env.NODE_ENV !== 'production' && <DevStylesLink />}
24917
{tags.map((tag) => (
25018
<Asset {...tag} key={`tsr-meta-${JSON.stringify(tag)}`} nonce={nonce} />
25119
))}
25220
</>
25321
)
25422
}
255-
256-
function uniqBy<T>(arr: Array<T>, fn: (item: T) => string) {
257-
const seen = new Set<string>()
258-
return arr.filter((item) => {
259-
const key = fn(item)
260-
if (seen.has(key)) {
261-
return false
262-
}
263-
seen.add(key)
264-
return true
265-
})
266-
}

0 commit comments

Comments
 (0)