Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 417dadc

Browse files
harlan-zwclaude
andauthored
feat(react): add @unhead/react/helmet compat export (#719)
* feat(react): add `@unhead/react/helmet` compat subpath export Provides a drop-in `<Helmet>` component for users migrating from react-helmet. Supports `defaultTitle`, `titleTemplate`, `onChangeClientState`, and standard JSX children — users only need to change their import path. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: update export snapshot for @unhead/react/helmet Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(react): address review feedback for Helmet compat - Fix falsy inner content check (data.children != null) to preserve 0 values - Always register onRendered hook so late-bound onChangeClientState works - Update JSDoc to clarify addedTags/removedTags are always empty - Add tests for: falsy content, multiple meta/link, style, noscript, base, invalid children, no children with defaultTitle Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: remove unused variable in helmet test Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: resolve TypeScript errors in Helmet compat - Fix generic type for createHead to use UseHeadInput - Use non-null assertion for singleton return - Remove unused @ts-expect-error directive Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(react): address CodeRabbit review feedback for Helmet compat - Add SSR support: create head entry during render when head.ssr is true - Normalize <html>/<body> children to htmlAttrs/bodyAttrs for react-helmet compat - Fix broken anchor link in migration docs - Fix lint errors (import sort, TypeError, if-newline) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(react): support prop-based API for Helmet compat Add support for react-helmet's prop-based API: title, meta, link, script, style, noscript, base, htmlAttributes, and bodyAttributes. Children take precedence over props when both are provided. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 5b5334e commit 417dadc

6 files changed

Lines changed: 676 additions & 21 deletions

File tree

docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ navigation:
55
title: 'Migrate React Helmet'
66
---
77

8-
**Quick Answer:** Replace `<Helmet>` with `useHead()` or `<Head>` components. Most props map directly - just change the import to `@unhead/react`.
8+
**Quick Answer:** For the fastest migration, swap your import to `@unhead/react/helmet` — it provides a drop-in `<Helmet>` component with the same API. Or replace `<Helmet>` with `useHead()` or `<Head>` for the full Unhead experience.
99

1010
## Why Migrate from React Helmet?
1111

@@ -56,38 +56,54 @@ function App() {
5656

5757
## How Do I Migrate from React Helmet to Unhead?
5858

59-
### 1. Update Dependencies
59+
### Quick Migration with `@unhead/react/helmet`
6060

61-
Remove React Helmet and install Unhead:
61+
For the fastest migration path, Unhead provides a drop-in `<Helmet>` component that supports the same API as react-helmet — including `defaultTitle`, `titleTemplate`, and `onChangeClientState`.
62+
63+
#### 1. Swap Your Dependencies
6264

6365
```bash
6466
npm remove react-helmet
6567
npm install @unhead/react
6668
```
6769

68-
### 2. Add the Provider
70+
#### 2. Update Your Imports
6971

70-
Unlike React Helmet, Unhead uses a provider, providing a safer context for managing head tags.
72+
```diff
73+
-import { Helmet } from 'react-helmet'
74+
+import { Helmet } from '@unhead/react/helmet'
75+
```
7176

72-
Add it to your app's entry point:
77+
That's it your existing `<Helmet>` usage will work as-is. On the client, `<Helmet>` automatically creates and manages a head instance, so no provider is needed. The `defaultTitle`, `titleTemplate`, and `onChangeClientState` props are all supported.
7378

74-
```tsx [src/entry-client.tsx]
75-
import { createHead, UnheadProvider } from '@unhead/react/client'
79+
::note
80+
For SSR, you still need to wrap your app with `<UnheadProvider>` so you can pass the head instance to `renderSSRHead()`. See [Update Server Rendering](#4-update-server-rendering) below.
81+
::
7682

77-
const head = createHead()
83+
```tsx
84+
import { Helmet } from '@unhead/react/helmet'
7885

7986
function App() {
8087
return (
81-
<UnheadProvider head={head}>
82-
<YourApp />
83-
</UnheadProvider>
88+
<Helmet
89+
defaultTitle="My Site"
90+
titleTemplate="%s | My Site"
91+
onChangeClientState={(newState) => console.log(newState)}
92+
>
93+
<title>Page Title</title>
94+
<meta name="description" content="Description" />
95+
</Helmet>
8496
)
8597
}
8698
```
8799

88-
### 3. Replace Components
100+
::note
101+
The `encodeSpecialCharacters` and `defer` props are accepted for compatibility but have no effect — Unhead handles these automatically.
102+
::
103+
104+
### 3. Full Migration to `<Head>` (Optional)
89105

90-
Replace all instances of `<Helmet>` with `<Head>`.
106+
If you'd prefer to adopt the Unhead API directly, replace `<Helmet>` with `<Head>`:
91107

92108
```diff
93109
-import { Helmet } from 'react-helmet'
@@ -96,10 +112,10 @@ Replace all instances of `<Helmet>` with `<Head>`.
96112
function Title() {
97113
return (
98114
<div>
99-
- <Helmet title-template="%s | My Site" />
115+
- <Helmet titleTemplate="%s | My Site">
100116
- <title>Hello World</title>
101117
- </Helmet>
102-
+ <Head title-template="%s | My Site">
118+
+ <Head titleTemplate="%s | My Site">
103119
+ <title>Hello World</title>
104120
+ </Head>
105121
<h1>Hello World</h1>
@@ -108,11 +124,6 @@ return (
108124
}
109125
```
110126

111-
There are some nuanced differences to be aware of:
112-
113-
- `defaultTitle` is not supported
114-
- `onChangeClientState` is not supported
115-
116127
### 4. Update Server Rendering
117128

118129
If you're using SSR, update your server code:

packages/react/build.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ export default defineBuildConfig({
1717
{ input: 'src/utils', name: 'utils' },
1818
{ input: 'src/plugins', name: 'plugins' },
1919
{ input: 'src/stream/vite', name: 'stream/vite' },
20+
{ input: 'src/helmet', name: 'helmet' },
2021
],
2122
})

packages/react/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@
4848
"./stream/vite": {
4949
"types": "./dist/stream/vite.d.ts",
5050
"default": "./dist/stream/vite.mjs"
51+
},
52+
"./helmet": {
53+
"types": "./dist/helmet.d.ts",
54+
"default": "./dist/helmet.mjs"
5155
}
5256
},
5357
"main": "dist/index.mjs",
@@ -75,6 +79,9 @@
7579
],
7680
"stream/vite": [
7781
"dist/stream/vite"
82+
],
83+
"helmet": [
84+
"dist/helmet"
7885
]
7986
}
8087
},

packages/react/src/helmet.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import type { ReactNode } from 'react'
2+
import type { ActiveHeadEntry, Unhead, ResolvableHead as UseHeadInput } from 'unhead/types'
3+
import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react'
4+
import { createHead as _createHead, createDebouncedFn, createDomRenderer } from 'unhead/client'
5+
import { HasElementTags, TagsWithInnerContent, ValidHeadTags } from 'unhead/utils'
6+
import { UnheadContext } from './context'
7+
8+
let _singletonHead: Unhead | null = null
9+
10+
function useHelmetHead(): Unhead {
11+
const ctx = useContext<Unhead | null>(UnheadContext)
12+
if (ctx) {
13+
return ctx
14+
}
15+
// Lazily create a singleton client head when no provider is present (client-only)
16+
if (!_singletonHead) {
17+
if (typeof window === 'undefined') {
18+
throw new TypeError('Helmet requires UnheadProvider on the server. Wrap your app with <UnheadProvider>.')
19+
}
20+
const domRenderer = createDomRenderer()
21+
let head: ReturnType<typeof _createHead<UseHeadInput>>
22+
const debouncedRenderer = createDebouncedFn(() => domRenderer(head), fn => setTimeout(fn, 0))
23+
head = _createHead<UseHeadInput>({ render: debouncedRenderer })
24+
_singletonHead = head
25+
}
26+
return _singletonHead!
27+
}
28+
29+
export interface HelmetProps {
30+
children?: ReactNode
31+
/**
32+
* A default title to use when no `<title>` child is provided.
33+
*
34+
* Equivalent to react-helmet's `defaultTitle`.
35+
*/
36+
defaultTitle?: string
37+
/**
38+
* A template for the title. Use `%s` as a placeholder for the page title.
39+
*
40+
* @example `%s | My Site`
41+
*/
42+
titleTemplate?: string
43+
/**
44+
* Called after the document head has been updated in the browser.
45+
*
46+
* Equivalent to react-helmet's `onChangeClientState`.
47+
*
48+
* @param newState - The new head state after rendering.
49+
* @param addedTags - Always empty — unhead manages DOM diffing internally.
50+
* @param removedTags - Always empty — unhead manages DOM diffing internally.
51+
*/
52+
onChangeClientState?: (newState: Record<string, any>, addedTags: Record<string, HTMLElement[]>, removedTags: Record<string, HTMLElement[]>) => void
53+
/**
54+
* Whether to encode special characters in attributes.
55+
*
56+
* @default true
57+
* @deprecated Unhead handles encoding automatically. This prop is accepted for compatibility but has no effect.
58+
*/
59+
encodeSpecialCharacters?: boolean
60+
/**
61+
* Whether to defer DOM updates until the browser is idle.
62+
*
63+
* @default true
64+
* @deprecated Unhead batches DOM updates automatically. This prop is accepted for compatibility but has no effect.
65+
*/
66+
defer?: boolean
67+
68+
// Prop-based API (alternative to children)
69+
title?: string
70+
base?: Record<string, any>
71+
meta?: Array<Record<string, any>>
72+
link?: Array<Record<string, any>>
73+
script?: Array<Record<string, any>>
74+
style?: Array<Record<string, any>>
75+
noscript?: Array<Record<string, any>>
76+
htmlAttributes?: Record<string, any>
77+
bodyAttributes?: Record<string, any>
78+
}
79+
80+
/**
81+
* A react-helmet compatible component powered by unhead.
82+
*
83+
* Drop-in replacement for `<Helmet>` — import from `@unhead/react/helmet` instead of `react-helmet`.
84+
*
85+
* @example
86+
* ```tsx
87+
* import { Helmet } from '@unhead/react/helmet'
88+
*
89+
* <Helmet
90+
* defaultTitle="My Site"
91+
* titleTemplate="%s | My Site"
92+
* onChangeClientState={(newState) => console.log(newState)}
93+
* >
94+
* <title>Page Title</title>
95+
* <meta name="description" content="Page description" />
96+
* </Helmet>
97+
* ```
98+
*/
99+
const Helmet: React.FC<HelmetProps> = ({
100+
children,
101+
defaultTitle,
102+
titleTemplate,
103+
onChangeClientState,
104+
title: titleProp,
105+
base: baseProp,
106+
meta: metaProp,
107+
link: linkProp,
108+
script: scriptProp,
109+
style: styleProp,
110+
noscript: noscriptProp,
111+
htmlAttributes,
112+
bodyAttributes,
113+
// accepted for compat, intentionally unused
114+
encodeSpecialCharacters: _encodeSpecialCharacters,
115+
defer: _defer,
116+
}) => {
117+
const head = useHelmetHead()
118+
119+
const processedElements = useMemo(() =>
120+
React.Children.toArray(children).filter(React.isValidElement), [children])
121+
122+
const getHeadChanges = useCallback(() => {
123+
const input: UseHeadInput = {}
124+
125+
if (titleTemplate) {
126+
input.titleTemplate = titleTemplate
127+
}
128+
129+
// Apply prop-based API values first (children override these)
130+
if (titleProp != null) {
131+
input.title = titleProp
132+
}
133+
if (baseProp) {
134+
input.base = baseProp as any
135+
}
136+
if (metaProp) {
137+
input.meta = [...metaProp] as any
138+
}
139+
if (linkProp) {
140+
input.link = [...linkProp] as any
141+
}
142+
if (scriptProp) {
143+
input.script = [...scriptProp] as any
144+
}
145+
if (styleProp) {
146+
input.style = [...styleProp] as any
147+
}
148+
if (noscriptProp) {
149+
input.noscript = [...noscriptProp] as any
150+
}
151+
if (htmlAttributes) {
152+
input.htmlAttrs = htmlAttributes as any
153+
}
154+
if (bodyAttributes) {
155+
input.bodyAttrs = bodyAttributes as any
156+
}
157+
158+
let hasTitle = !!titleProp
159+
for (const element of processedElements) {
160+
const reactElement = element as React.ReactElement
161+
const { type, props } = reactElement
162+
let tagName = String(type)
163+
164+
// Normalize react-helmet's <html>/<body> to unhead's htmlAttrs/bodyAttrs
165+
if (tagName === 'html')
166+
tagName = 'htmlAttrs'
167+
else if (tagName === 'body')
168+
tagName = 'bodyAttrs'
169+
170+
if (!ValidHeadTags.has(tagName)) {
171+
continue
172+
}
173+
174+
const data: Record<string, any> = { ...(typeof props === 'object' ? props : {}) }
175+
176+
if (TagsWithInnerContent.has(tagName) && data.children != null) {
177+
const contentKey = tagName === 'script' ? 'innerHTML' : 'textContent'
178+
data[contentKey] = Array.isArray(data.children)
179+
? data.children.map(String).join('')
180+
: String(data.children)
181+
}
182+
delete data.children
183+
184+
if (tagName === 'title') {
185+
hasTitle = true
186+
}
187+
188+
if (HasElementTags.has(tagName)) {
189+
const key = tagName as keyof UseHeadInput
190+
if (!Array.isArray(input[key])) {
191+
// @ts-expect-error untyped
192+
input[key] = []
193+
}
194+
(input[key] as any[])!.push(data)
195+
}
196+
else {
197+
// @ts-expect-error untyped
198+
input[tagName as keyof UseHeadInput] = data
199+
}
200+
}
201+
202+
// Apply defaultTitle when no title is provided via props or children
203+
if (!hasTitle && defaultTitle) {
204+
input.title = defaultTitle
205+
}
206+
207+
// Pass defaultTitle through templateParams for titleTemplate support
208+
if (defaultTitle) {
209+
input.templateParams = {
210+
...input.templateParams as any,
211+
defaultTitle,
212+
}
213+
}
214+
215+
return input
216+
}, [processedElements, titleTemplate, defaultTitle, titleProp, baseProp, metaProp, linkProp, scriptProp, styleProp, noscriptProp, htmlAttributes, bodyAttributes])
217+
218+
const headRef = useRef<ActiveHeadEntry<any> | null>(null)
219+
220+
// Map onChangeClientState to unhead's onRendered hook
221+
const onChangeClientStateRef = useRef(onChangeClientState)
222+
onChangeClientStateRef.current = onChangeClientState
223+
224+
// Server: create entry during render since useEffect doesn't run in SSR
225+
if (head.ssr && !headRef.current) {
226+
headRef.current = head.push(getHeadChanges())
227+
}
228+
229+
// Client: create entry in effect to avoid orphaned entries in React 18 StrictMode
230+
useEffect(() => {
231+
const options: Record<string, any> = {
232+
onRendered: () => {
233+
const cb = onChangeClientStateRef.current
234+
if (!cb)
235+
return
236+
// Build a state object similar to react-helmet's onChangeClientState
237+
const titleEl = document.querySelector('title')
238+
const state: Record<string, any> = {
239+
title: titleEl?.textContent || '',
240+
}
241+
// Collect current meta/link/script tags
242+
for (const tag of ['meta', 'link', 'script', 'style', 'base'] as const) {
243+
state[`${tag}Tags`] = Array.from(document.querySelectorAll(`head ${tag}`))
244+
}
245+
// addedTags/removedTags are always empty since unhead manages DOM diffing internally
246+
cb(state, {}, {})
247+
},
248+
}
249+
headRef.current = head.push(getHeadChanges(), options)
250+
return () => {
251+
headRef.current?.dispose()
252+
headRef.current = null
253+
}
254+
}, [head])
255+
256+
useEffect(() => {
257+
headRef.current?.patch(getHeadChanges())
258+
}, [getHeadChanges])
259+
260+
return null
261+
}
262+
263+
export { Helmet }

0 commit comments

Comments
 (0)