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

Skip to content

feat(react): add @unhead/react/helmet compat export#719

Merged
harlan-zw merged 8 commits into
mainfrom
feat/react-helmet-compat
Apr 6, 2026
Merged

feat(react): add @unhead/react/helmet compat export#719
harlan-zw merged 8 commits into
mainfrom
feat/react-helmet-compat

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Apr 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds @unhead/react/helmet subpath export with a drop-in <Helmet> component for users migrating from react-helmet
  • Supports defaultTitle, titleTemplate, onChangeClientState props, and standard JSX children (<title>, <meta>, <link>, etc.)
  • encodeSpecialCharacters and defer props accepted for compat but are no-ops (unhead handles these natively)
  • Updates migration docs to recommend the quick import-swap path

Migration for users is:

-import { Helmet } from 'react-helmet'
+import { Helmet } from '@unhead/react/helmet'

Test plan

  • 9 new tests covering: basic JSX children, titleTemplate, defaultTitle, defaultTitle with titleTemplate, onChangeClientState, script innerHTML, deprecated props, and unmount cleanup
  • All 68 existing React package tests still pass
  • Build succeeds with new dist/helmet.mjs entry

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a React Helmet–compatible component with defaultTitle, titleTemplate, onChangeClientState and compatibility props; published as a dedicated package entry.
  • Documentation

    • Updated migration guide with a quick migration path to the compatible , client-side examples, SSR notes, and a compatibility note that encodeSpecialCharacters/defer are accepted but have no effect.
  • Tests

    • Added SSR and client tests validating rendering, syncing, fallbacks, and cleanup.

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]>
@coderabbitai

coderabbitai Bot commented Apr 5, 2026

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a React-compatible Helmet component backed by Unhead, exposes it via build/package exports, adds tests for SSR and client sync, and updates migration docs to recommend @unhead/react/helmet as a drop-in migration path while noting SSR provider requirements and no-op compatibility props.

Changes

Cohort / File(s) Summary
Build & Package Export
packages/react/build.config.ts, packages/react/package.json
Added build entry for src/helmet and a ./helmet package export (runtime ./dist/helmet.mjs, types ./dist/helmet.d.ts) plus typesVersions mapping.
React Helmet Implementation
packages/react/src/helmet.ts
New Helmet component and HelmetProps implementing react-helmet compatibility on Unhead: provider detection with client singleton fallback, child→head conversion, tag validation, titleTemplate/defaultTitle handling, acceptance of legacy props (encodeSpecialCharacters, defer), SSR push and client onRendered handling calling onChangeClientState.
Tests
packages/react/test/helmet.test.tsx
New Vitest/JSDOM tests covering SSR output, DOM rendering, title templating, defaultTitle precedence, multiple tag types, script/style/base/noscript/JSON scripts, deprecated-prop acceptance, provider/no-provider flows, cleanup on unmount, and falsy title preservation.
Docs
docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md
Migration guide reworked to prioritize quick import swap to @unhead/react/helmet with examples; clarifies SSR requires <UnheadProvider> for renderSSRHead() and notes encodeSpecialCharacters/defer are accepted but no-ops; updates <Head> prop naming to titleTemplate.
Exports Test Manifest
test/exports/react.yaml
Added ./helmet export entry exposing Helmet as function for export validation.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 A helmet stitched from head tags bright,
I hop through titles, meta, and light,
Default titles, templates hum in tune,
SSR nods, the client syncs by noon,
Carrots for compatibility — hop! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding a @unhead/react/helmet compatibility export with a Helmet component.
Description check ✅ Passed The description includes a clear summary, implementation details, migration path, test coverage, and build verification, but does not follow the provided template structure with checkboxes for type of change or linked issue.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/react-helmet-compat

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.6 kB 4.3 kB
Server (Minimal) 10.3 kB 4.2 kB
Vue Client (Minimal) 11.6 kB 4.8 kB
Vue Server (Minimal) 11.3 kB 4.6 kB

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between f88c846 and 7af9fa8.

📒 Files selected for processing (5)
  • docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md
  • packages/react/build.config.ts
  • packages/react/package.json
  • packages/react/src/helmet.ts
  • packages/react/test/helmet.test.tsx

Comment thread packages/react/src/helmet.ts Outdated
Comment thread packages/react/src/helmet.ts Outdated
Comment thread packages/react/src/helmet.ts Outdated
Comment on lines +150 to +166
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, {}, {})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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:


🏁 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 -100

Repository: unjs/unhead

Length of output: 82


🏁 Script executed:

# Find unhead package files if available in the repository
fd -i unhead | head -20

Repository: unjs/unhead

Length of output: 233


🏁 Script executed:

# Search for helmet.ts implementation and related unhead integration
cat packages/react/src/helmet.ts | head -100

Repository: 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 -200

Repository: 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 3

Repository: unjs/unhead

Length of output: 1193


🏁 Script executed:

# Search for unhead head.push implementation
find packages/unhead/src -name "*.ts" -type f | head -20

Repository: 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 -150

Repository: 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/null

Repository: 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]>
harlan-zw and others added 2 commits April 6, 2026 03:34
- 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]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 7af9fa8 and b0d5938.

📒 Files selected for processing (4)
  • docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md
  • packages/react/src/helmet.ts
  • packages/react/test/helmet.test.tsx
  • test/exports/react.yaml
✅ Files skipped from review due to trivial changes (1)
  • test/exports/react.yaml

Comment on lines +79 to +80
::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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
::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.

Comment thread packages/react/src/helmet.ts
Comment on lines +175 to +185
// 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, {}, {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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:


🏁 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 20

Repository: 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.md

Repository: 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.

Comment thread packages/react/test/helmet.test.tsx Outdated
harlan-zw and others added 2 commits April 6, 2026 13:55
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]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/react/src/helmet.ts (2)

175-185: ⚠️ Potential issue | 🟠 Major

onChangeClientState is still only partially react-helmet-compatible.

The newState object here omits noscriptTags, htmlAttributes, and bodyAttributes, and it reports baseTags instead of baseTag. That still breaks the compatibility contract declared for onChangeClientState.

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 | 🔴 Critical

Register the Unhead entry during render for SSR.

head.push() still only runs inside useEffect(). React does not execute effects during renderToString(), so this component contributes nothing to renderSSRHead(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

📥 Commits

Reviewing files that changed from the base of the PR and between af97f17 and 7947c15.

📒 Files selected for processing (2)
  • packages/react/src/helmet.ts
  • packages/react/test/helmet.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react/test/helmet.test.tsx

Comment thread packages/react/src/helmet.ts Outdated
- 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]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/react/src/helmet.ts (1)

181-197: ⚠️ Potential issue | 🟠 Major

onChangeClientState is still only a partial shim.

The callback payload omits parts of the react-helmet contract (htmlAttributes, bodyAttributes, noscriptTags, and the singular baseTag), and addedTags / removedTags are 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7947c15 and 426466a.

📒 Files selected for processing (3)
  • docs/0.react/head/guides/0.get-started/migrate-from-react-helmet.md
  • packages/react/src/helmet.ts
  • packages/react/test/helmet.test.tsx
✅ Files skipped from review due to trivial changes (1)
  • packages/react/test/helmet.test.tsx

Comment thread packages/react/src/helmet.ts
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]>
@harlan-zw harlan-zw merged commit 417dadc into main Apr 6, 2026
6 checks passed
@harlan-zw harlan-zw mentioned this pull request Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant