feat(react): add @unhead/react/helmet compat export#719
Conversation
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]>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a React-compatible Changes
Sequence Diagram(s)sequenceDiagram
participant App as "React App"
participant Helmet as "Helmet"
participant Unhead as "Unhead (head)"
participant Browser as "Browser DOM"
participant SSR as "SSR renderer"
App->>Helmet: mount(children, props)
Helmet->>Unhead: obtain context or create client singleton
Helmet->>Unhead: push(headInput, onRendered)
Note right of Unhead: compute & apply head changes
Unhead->>Browser: update document.head
Unhead->>Helmet: invoke onRendered()
Helmet->>Browser: read document.head (title/meta/link/script/style/base)
Helmet->>App: call onChangeClientState(newState, addedTags, removedTags)
Helmet->>Unhead: patch(...) on updates / remove on unmount
SSR->>Unhead: renderSSRHead(head) -> HTML
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Bundle Size Analysis
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/react/src/helmet.ts`:
- Around line 99-104: The current check in helmet.ts that sets inner content for
TagsWithInnerContent uses "&& data.children", which incorrectly drops falsy but
valid values like 0 and ''; change the presence test to check for null/undefined
(e.g., data.children != null or typeof data.children !== 'undefined') so values
like 0 and empty string are preserved, then keep the existing logic that sets
contentKey (script -> innerHTML else textContent) and converts arrays via
Array.isArray(data.children). Ensure you update the conditional around
TagsWithInnerContent.has(tagName) to use this null/undefined check so valid
falsy children are not lost.
- Around line 150-166: options.onRendered currently ignores the render context
and always calls cb(state, {}, {}); change options.onRendered to accept the
render context (the renders array of DomRenderTagContext) and compute addedTags
and removedTags by iterating renders (use the DomRenderTagContext properties to
group tags by tag type and collect added vs removed entries), then call
onChangeClientStateRef.current with cb(state, addedTags, removedTags). Update
the implementation referenced by options.onRendered and use the existing
onChangeClientStateRef and the title/meta/link/script/style/base logic to build
the state while deriving and supplying the real added/removed tag maps instead
of empty objects.
- Around line 149-173: The onRendered handler is only being attached when
onChangeClientStateRef.current is truthy on first effect run; change this by
always assigning options.onRendered = () => { const cb =
onChangeClientStateRef.current; if (!cb) return; /* build state, collect tags,
call cb(state, {}, {}) */ } (i.e. move the assignment outside the if guard),
keeping the internal check for cb so the latest prop is used; leave
headRef.current = head.push(getHeadChanges(), options) and the cleanup
(headRef.current?.dispose()) as-is so the callback registers even if the prop is
added later.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9832c7fc-4434-44f2-ae68-7fd84df9dfe1
📒 Files selected for processing (5)
docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.mdpackages/react/build.config.tspackages/react/package.jsonpackages/react/src/helmet.tspackages/react/test/helmet.test.tsx
| options.onRendered = () => { | ||
| const cb = onChangeClientStateRef.current | ||
| if (!cb) | ||
| return | ||
| // Build a state object similar to react-helmet's onChangeClientState | ||
| const titleEl = document.querySelector('title') | ||
| const state: Record<string, any> = { | ||
| title: titleEl?.textContent || '', | ||
| } | ||
| // Collect current meta/link/script tags | ||
| for (const tag of ['meta', 'link', 'script', 'style', 'base'] as const) { | ||
| state[`${tag}Tags`] = Array.from(document.querySelectorAll(`head ${tag}`)) | ||
| } | ||
| // react-helmet passes (newState, addedTags, removedTags) | ||
| // We provide the state and empty add/remove maps since unhead manages diffing internally | ||
| cb(state, {}, {}) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In unhead v3, what fields are available on DomRenderTagContextin theonRendered hook, and how can added vs removed DOM tags be derived from it?
💡 Result:
In Unhead v3, the dom:rendered hook has been removed according to the official upgrade guides. Instead, perform post-rendering logic after calling renderDOMHead(head, { document }), which is now synchronous. For reference from v2 documentation (still relevant for understanding the context structure), the DomRenderTagContext on the dom:rendered hook (ctx: { renders: DomRenderTagContext[] }) has these fields: - id: string - $el: Element - shouldRender: boolean - tag: HeadTag - entry?: HeadEntry - markSideEffect: (key: string, fn: => void) => void To derive added vs removed DOM tags after renderDOMHead: 1. Before calling renderDOMHead, collect existing tag IDs from the DOM (e.g., query selectors for head elements with data-unhead-id or similar tracking attributes, or track by id if set). 2. Call renderDOMHead(head, { document }). 3. After rendering, collect the new set of tag elements/IDs from the DOM. 4. Compare the two sets: - Added: new IDs not in previous set. - Removed: previous IDs not in new set. You can access the resolved tags via head.resolveTags before rendering to know what should be added/removed, and use the $el references if available through custom hooks or tracking. In v3, since dom:renderTag hook is also removed and rendering is synchronous, direct element access during render is not via hooks, but you can query the DOM post-render using tag props like key for identification.
Citations:
- 1: https://unhead.unjs.io/docs/react/head/api/hooks/dom-render-tag
- 2: https://unhead.unjs.io/docs/react/head/api/hooks/dom-rendered
- 3: https://unhead.unjs.io/docs/react/head/guides/get-started/migration
- 4: https://unhead.unjs.io/docs/vue/head/api/hooks/dom-rendered
- 5: https://unhead.unjs.io/docs/solid-js/head/guides/get-started/migration
🏁 Script executed:
# Search for unhead documentation and implementation regarding onRendered hook
rg -i "onRendered|dom.*rendered" --type ts --type tsx --type js --type jsx -B 2 -A 5 | head -100Repository: unjs/unhead
Length of output: 82
🏁 Script executed:
# Find unhead package files if available in the repository
fd -i unhead | head -20Repository: unjs/unhead
Length of output: 233
🏁 Script executed:
# Search for helmet.ts implementation and related unhead integration
cat packages/react/src/helmet.ts | head -100Repository: unjs/unhead
Length of output: 3236
🏁 Script executed:
# Read more of the helmet.ts file to see the full onRendered implementation
tail -n +80 packages/react/src/helmet.ts | head -200Repository: unjs/unhead
Length of output: 3151
🏁 Script executed:
# Search for how resolveTags or head tracking is used in the codebase
rg "resolveTags|addedTags|removedTags" packages/react/src/ -B 2 -A 3Repository: unjs/unhead
Length of output: 1193
🏁 Script executed:
# Search for unhead head.push implementation
find packages/unhead/src -name "*.ts" -type f | head -20Repository: unjs/unhead
Length of output: 843
🏁 Script executed:
# Look for head.push or onRendered in unhead package
rg "onRendered|head\.push" packages/unhead/src -B 3 -A 5 | head -150Repository: unjs/unhead
Length of output: 9851
🏁 Script executed:
# Check the head API type definition
fd "index\\.d\\.ts|types\\.ts" packages/unhead/src | xargs grep -l "onRendered\|push" 2>/dev/nullRepository: unjs/unhead
Length of output: 39
onChangeClientState callback receives empty addedTags/removedTags objects but prop type promises them.
The onRendered hook in unhead provides a renders array of DomRenderTagContext objects that can be compared against previous state to derive added/removed tags. Currently, the implementation ignores this context entirely and always passes empty objects to the callback (cb(state, {}, {})).
Either track and pass the actual added/removed tags from the render context, or document this as a compatibility limitation in the prop type and component docs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/react/src/helmet.ts` around lines 150 - 166, options.onRendered
currently ignores the render context and always calls cb(state, {}, {}); change
options.onRendered to accept the render context (the renders array of
DomRenderTagContext) and compute addedTags and removedTags by iterating renders
(use the DomRenderTagContext properties to group tags by tag type and collect
added vs removed entries), then call onChangeClientStateRef.current with
cb(state, addedTags, removedTags). Update the implementation referenced by
options.onRendered and use the existing onChangeClientStateRef and the
title/meta/link/script/style/base logic to build the state while deriving and
supplying the real added/removed tag maps instead of empty objects.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- 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]>
Helmet now lazily creates a singleton head instance when no provider context is found, so client-side usage requires zero setup. SSR still requires UnheadProvider for renderSSRHead() access. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md`:
- Around line 79-80: The fragment link in the migration note
("#_4-update-server-rendering") is incorrect and points to a dead anchor; update
the fragment to match the actual heading slug for the "Update Server Rendering"
section (e.g. replace "#_4-update-server-rendering" with the correct slug
generated from the heading text, such as "#update-server-rendering" or the exact
slug used below) so the note pointing to UnheadProvider correctly jumps to the
"Update Server Rendering" heading.
In `@packages/react/src/helmet.ts`:
- Around line 175-185: The onChangeClientState payload built in helmet.ts
currently uses keys like baseTags and lacks react-helmet fields (baseTag,
bodyAttributes, htmlAttributes, noscriptTags), so update the payload
construction (the block that builds state using titleEl and the loop over
['meta','link','script','style','base']) to produce a react-helmet-compatible
shape: include title, metaTags, linkTags, scriptTags, styleTags, baseTag
(singular, normalized from the head base element(s)), noscriptTags, and add
bodyAttributes and htmlAttributes (populated from document.body and
document.documentElement respectively); then call cb(state, {}, {}) as before so
consumers expecting react-helmet names receive the correct fields. Ensure you
reference the same variables (titleEl, the tag loop, and cb) when making these
key/name changes.
- Around line 169-193: The Helmet component currently calls
head.push(getHeadChanges(), options) inside useEffect (symbols: useEffect,
head.push, getHeadChanges, headRef, onChangeClientStateRef), which prevents SSR
registration; move the head.push(getHeadChanges(), options) call out of the
useEffect so it runs during render (assign the returned entry to headRef.current
during render), and keep the useEffect only to wire the client-side onRendered
callback and to dispose/reset headRef.current on unmount (use
onChangeClientStateRef inside the effect for callback wiring and call
headRef.current?.dispose() in the cleanup).
In `@packages/react/test/helmet.test.tsx`:
- Around line 259-264: The test destructures an unused "container" from the call
to render in the Helmet test; remove the unused binding by calling render(...)
directly (or assign its return to an underscore/ignored variable) so the render
result isn't destructured into an unused "container" variable; update the line
that currently reads const { container } = render(...) to simply call
render(...) while keeping the Helmet element and assertions unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7be6a3fd-dc1e-43e8-a767-f8fc3754eb7a
📒 Files selected for processing (4)
docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.mdpackages/react/src/helmet.tspackages/react/test/helmet.test.tsxtest/exports/react.yaml
✅ Files skipped from review due to trivial changes (1)
- test/exports/react.yaml
| ::note | ||
| 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. |
There was a problem hiding this comment.
Fix the broken section link.
#_4-update-server-rendering does not match the heading slug below, so this migration note points to a dead fragment.
Suggested fix
-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.
+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.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ::note | |
| 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. | |
| ::note | |
| 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. |
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 80-80: Link fragments should be valid
(MD051, link-fragments)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md` around
lines 79 - 80, The fragment link in the migration note
("#_4-update-server-rendering") is incorrect and points to a dead anchor; update
the fragment to match the actual heading slug for the "Update Server Rendering"
section (e.g. replace "#_4-update-server-rendering" with the correct slug
generated from the heading text, such as "#update-server-rendering" or the exact
slug used below) so the note pointing to UnheadProvider correctly jumps to the
"Update Server Rendering" heading.
| // Build a state object similar to react-helmet's onChangeClientState | ||
| const titleEl = document.querySelector('title') | ||
| const state: Record<string, any> = { | ||
| title: titleEl?.textContent || '', | ||
| } | ||
| // Collect current meta/link/script tags | ||
| for (const tag of ['meta', 'link', 'script', 'style', 'base'] as const) { | ||
| state[`${tag}Tags`] = Array.from(document.querySelectorAll(`head ${tag}`)) | ||
| } | ||
| // addedTags/removedTags are always empty since unhead manages DOM diffing internally | ||
| cb(state, {}, {}) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the actual code at the specified lines
cd packages/react && cat -n src/helmet.ts | sed -n '170,190p'Repository: unjs/unhead
Length of output: 1060
🌐 Web query:
react-helmet onChangeClientState callback signature documentation
💡 Result:
The onChangeClientState callback in react-helmet (v6.1.0, last release 2020-06-08) has the signature: (newState, addedTags, removedTags) => {}. It is called on the client-side whenever Helmet updates the document head DOM. newState is an object containing the aggregated Helmet state with properties like baseTag, bodyAttributes, htmlAttributes, linkTags, metaTags, noscriptTags, scriptTags, styleTags, title (from source code snippets in issues and docs). addedTags and removedTags are arrays of the tag elements that were added or removed from the DOM during this update (used e.g. to access script onload). This is documented in the official README reference guide with example usage, and confirmed across GitHub issues #115, #146, #328, npm page, and API docs. Note: Original react-helmet is unmaintained. Modern alternatives: react-helmet-async (supports React 16-19+, same callback signature for <React 19), @dr.pogodin/react-helmet (React 19+, same signature), or React 19 native hoisting (no callback needed).
Citations:
- 1: https://www.npmjs.com/package/react-helmet
- 2: https://github.com/nfl/react-helmet/blob/master/README.md
- 3: https://github.com/nfl/react-helmet
- 4: https://npmdoc.github.io/node-npmdoc-react-helmet/build/apidoc.html
- 5: Listen to title changes on the client nfl/react-helmet#115
- 6: How do I know the scripts are ready? nfl/react-helmet#146
- 7: https://www.npmjs.com/package/@dr.pogodin/react-helmet
- 8: https://github.com/staylor/react-helmet-async
🏁 Script executed:
# Let's also search for any migration guide or documentation in the repo
find . -type f \( -name "*.md" -o -name "*.ts" -o -name "*.tsx" \) | xargs rg -l "onChangeClientState|react-helmet" --max-count 20Repository: unjs/unhead
Length of output: 313
🏁 Script executed:
cat packages/react/docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md 2>/dev/null || \
cat docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.mdRepository: unjs/unhead
Length of output: 4886
🏁 Script executed:
# Also check the test file to see what's expected from onChangeClientState
cat -n packages/react/test/helmet.test.tsx | grep -A 20 -B 5 "onChangeClientState"Repository: unjs/unhead
Length of output: 1474
onChangeClientState payload is not react-helmet-compatible.
The implementation provides title, metaTags, linkTags, scriptTags, styleTags, and baseTags, but react-helmet callers expect baseTag (singular), bodyAttributes, htmlAttributes, and noscriptTags. The migration guide claims "supports the same API as react-helmet" for onChangeClientState, but this limitation is undocumented. Code relying on the documented react-helmet callback shape will break. Either normalize the payload to match react-helmet's field names, or document this incompatibility prominently in the migration guide.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/react/src/helmet.ts` around lines 175 - 185, The onChangeClientState
payload built in helmet.ts currently uses keys like baseTags and lacks
react-helmet fields (baseTag, bodyAttributes, htmlAttributes, noscriptTags), so
update the payload construction (the block that builds state using titleEl and
the loop over ['meta','link','script','style','base']) to produce a
react-helmet-compatible shape: include title, metaTags, linkTags, scriptTags,
styleTags, baseTag (singular, normalized from the head base element(s)),
noscriptTags, and add bodyAttributes and htmlAttributes (populated from
document.body and document.documentElement respectively); then call cb(state,
{}, {}) as before so consumers expecting react-helmet names receive the correct
fields. Ensure you reference the same variables (titleEl, the tag loop, and cb)
when making these key/name changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- 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]>
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
packages/react/src/helmet.ts (2)
175-185:⚠️ Potential issue | 🟠 Major
onChangeClientStateis still only partially react-helmet-compatible.The
newStateobject here omitsnoscriptTags,htmlAttributes, andbodyAttributes, and it reportsbaseTagsinstead ofbaseTag. That still breaks the compatibility contract declared foronChangeClientState.What fields does react-helmet pass in the newState argument of onChangeClientState(newState, addedTags, removedTags)?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/react/src/helmet.ts` around lines 175 - 185, The state object built for onChangeClientState is missing noscriptTags, htmlAttributes, and bodyAttributes, and it incorrectly uses baseTags (plural); update the construction around titleEl/state and the tag loop so it builds keys exactly like react-helmet expects: metaTags, linkTags, scriptTags, styleTags, baseTag (singular), noscriptTags, plus htmlAttributes and bodyAttributes; collect htmlAttributes from document.documentElement.attributes, bodyAttributes from document.body.attributes, and noscriptTags from head noscript nodes, and pass that complete state into cb(state, {}, {}) so onChangeClientState receives the full react-helmet-compatible newState.
169-188:⚠️ Potential issue | 🔴 CriticalRegister the Unhead entry during render for SSR.
head.push()still only runs insideuseEffect(). React does not execute effects duringrenderToString(), so this component contributes nothing torenderSSRHead(head)on the server.Does React execute useEffect during server-side rendering with renderToString?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/react/src/helmet.ts` around lines 169 - 188, The component currently calls head.push(...) only inside useEffect, so no entry is registered during render/SSR; to fix, call head.push(getHeadChanges(), optionsWithoutOnRendered) synchronously during render (store the returned entry in headRef.current) so renderSSRHead(head) sees it, then in useEffect add or replace the client-only onRendered callback (or update the entry) to attach the onRendered behavior for the browser; use the existing symbols head.push, headRef.current, getHeadChanges and the useEffect block to perform the client-side update/cleanup so SSR and client both get correct registration.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/react/src/helmet.ts`:
- Around line 113-116: The code derives tagName from the raw JSX element type
(variable tagName) and checks it against ValidHeadTags, but common patterns like
<html> and <body> are not canonicalized and therefore get skipped; update the
normalization logic in helmet.ts (where tagName is computed) to map 'html' ->
'htmlAttrs' and 'body' -> 'bodyAttrs' before calling ValidHeadTags.has(tagName)
so those elements are recognized; keep the rest of the validation flow unchanged
and only perform this string mapping immediately after computing tagName.
---
Duplicate comments:
In `@packages/react/src/helmet.ts`:
- Around line 175-185: The state object built for onChangeClientState is missing
noscriptTags, htmlAttributes, and bodyAttributes, and it incorrectly uses
baseTags (plural); update the construction around titleEl/state and the tag loop
so it builds keys exactly like react-helmet expects: metaTags, linkTags,
scriptTags, styleTags, baseTag (singular), noscriptTags, plus htmlAttributes and
bodyAttributes; collect htmlAttributes from document.documentElement.attributes,
bodyAttributes from document.body.attributes, and noscriptTags from head
noscript nodes, and pass that complete state into cb(state, {}, {}) so
onChangeClientState receives the full react-helmet-compatible newState.
- Around line 169-188: The component currently calls head.push(...) only inside
useEffect, so no entry is registered during render/SSR; to fix, call
head.push(getHeadChanges(), optionsWithoutOnRendered) synchronously during
render (store the returned entry in headRef.current) so renderSSRHead(head) sees
it, then in useEffect add or replace the client-only onRendered callback (or
update the entry) to attach the onRendered behavior for the browser; use the
existing symbols head.push, headRef.current, getHeadChanges and the useEffect
block to perform the client-side update/cleanup so SSR and client both get
correct registration.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ab01fe86-2638-4163-bc0f-34dcbb8b1b4d
📒 Files selected for processing (2)
packages/react/src/helmet.tspackages/react/test/helmet.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/react/test/helmet.test.tsx
- 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]>
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
packages/react/src/helmet.ts (1)
181-197:⚠️ Potential issue | 🟠 Major
onChangeClientStateis still only a partial shim.The callback payload omits parts of the react-helmet contract (
htmlAttributes,bodyAttributes,noscriptTags, and the singularbaseTag), andaddedTags/removedTagsare always{}. Consumers relying on react-helmet's callback shape will still break even though this export is positioned as a compat layer.According to the official react-helmet README, what shape does onChangeClientState(newState, addedTags, removedTags) expose, including baseTag, htmlAttributes, bodyAttributes, and noscriptTags?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/react/src/helmet.ts` around lines 181 - 197, The onRendered handler in useEffect currently builds a partial react-helmet state and calls cb(state, {}, {}); update it to produce the full react-helmet shape by including htmlAttributes (document.documentElement attributes), bodyAttributes (document.body attributes), baseTag (the singular <base> element or null), and noscriptTags (Array.from(document.querySelectorAll('noscript'))), and replace the always-empty addedTags/removedTags with actual arrays computed by diffing the current head tags against a previous snapshot stored in a ref (e.g., prevHeadTagsRef) that you update after calling onChangeClientStateRef.current; keep the existing title and tag collections but compute addedTags/removedTags from those collections and pass cb(state, addedTags, removedTags).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/react/src/helmet.ts`:
- Around line 29-52: HelmetProps currently lacks prop-based fields (title, meta,
link, script, style, noscript, base, htmlAttributes, bodyAttributes) so
prop-based react-helmet usage won't work; update the type and mapping in
getHeadChanges() to accept and translate these props into the UseHeadInput
shape. Specifically, extend the HelmetProps interface to include title?: string,
meta?: Array<Record<string, any>>, link?: Array<Record<string, any>>, script?:
Array<Record<string, any>>, style?: Array<Record<string, any>>, noscript?:
Array<Record<string, any>>, base?: Record<string, any>, htmlAttributes?:
Record<string, any>, bodyAttributes?: Record<string, any> (keep existing
defaultTitle/titleTemplate/onChangeClientState), then modify getHeadChanges(…)
to detect and merge values from those props into the produced UseHeadInput
(mapping title -> title, meta -> meta tags, link -> link tags, etc.), ensuring
children-based head entries are merged or overridden consistently and that
onChangeClientState/defaultTitle/titleTemplate behavior is preserved.
---
Duplicate comments:
In `@packages/react/src/helmet.ts`:
- Around line 181-197: The onRendered handler in useEffect currently builds a
partial react-helmet state and calls cb(state, {}, {}); update it to produce the
full react-helmet shape by including htmlAttributes (document.documentElement
attributes), bodyAttributes (document.body attributes), baseTag (the singular
<base> element or null), and noscriptTags
(Array.from(document.querySelectorAll('noscript'))), and replace the
always-empty addedTags/removedTags with actual arrays computed by diffing the
current head tags against a previous snapshot stored in a ref (e.g.,
prevHeadTagsRef) that you update after calling onChangeClientStateRef.current;
keep the existing title and tag collections but compute addedTags/removedTags
from those collections and pass cb(state, addedTags, removedTags).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ab9e38c4-85dd-4c9c-bdca-af95029f2bf7
📒 Files selected for processing (3)
docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.mdpackages/react/src/helmet.tspackages/react/test/helmet.test.tsx
✅ Files skipped from review due to trivial changes (1)
- packages/react/test/helmet.test.tsx
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]>
Summary
@unhead/react/helmetsubpath export with a drop-in<Helmet>component for users migrating from react-helmetdefaultTitle,titleTemplate,onChangeClientStateprops, and standard JSX children (<title>,<meta>,<link>, etc.)encodeSpecialCharactersanddeferprops accepted for compat but are no-ops (unhead handles these natively)Migration for users is:
Test plan
dist/helmet.mjsentry🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests