feat(nuxt): migrate to unhead v3#34793
feat(nuxt): migrate to unhead v3#34793
Conversation
- Bump @unhead/vue and unhead to 3.0.1 - Remove v2 `mode: 'server'` entry options (removed in v3) - Make renderSSRHead/renderDOMHead synchronous (v3 sync rendering) - Clean up importmap: auto-serialize innerHTML, rely on capo weight 25 - Clean up speculationrules: use defineScript, auto-serialize innerHTML - Use defineLink for strict type narrowing in payload composable - Fix strict type issues in head components (v3 discriminated unions) - Add new unhead config options: validate, canonical, minify, templateParams - Gate legacy mode and deprecated composables on compatibilityVersion >= 5 - Wire ValidatePlugin (dev), CanonicalPlugin, MinifyPlugin into module - Remove useServerHead/useServerHeadSafe/useServerSeoMeta from v5 auto-imports - Update test fixtures and composables test expectations
|
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
@nuxt/kit
@nuxt/nitro-server
nuxt
@nuxt/rspack-builder
@nuxt/schema
@nuxt/vite-builder
@nuxt/webpack-builder
commit: |
Merging this PR will degrade performance by 21.51%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | writeTypes in the basic-types fixture |
38.8 ms | 49.5 ms | -21.51% |
Comparing feat/unhead-v3 (676a4f6) with main (941d2c9)2
Footnotes
-
3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
-
No successful run was found on
main(96b14dd) during the generation of this report, so 941d2c9 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩
…@unhead/vue 3.0.2 re-exports defineLink/defineScript from @unhead/vue, removing the need for a direct `unhead` dependency. Switch imports accordingly and drop `unhead` from package.json deps.
…mposables to test list
…ime plugins - Remove CanonicalPlugin, ValidatePlugin, MinifyPlugin runtime registration for v5 - Register @unhead/vue/vite Unhead() build plugin when compatibilityVersion >= 5 - Configure rolldown/lightningcss minifiers by default - Expose `unhead.vite` config key for full vite plugin customization - Simplify unhead config to legacy, vite, renderSSRHeadOptions
…active template params)
…/vue/legacy Replaces the hand-rolled v4 compat plugin list (TemplateParamsPlugin, AliasSortingPlugin, DeprecationsPlugin) with the canonical `legacyPlugins` export from `@unhead/vue/legacy` shipped in 3.0.3. The v4 compat set now also includes `PromisesPlugin`, which is a no-op when no promise values are passed. This aligns Nuxt's v4 default with unhead's published "v2 compat set" and lets `legacy: true` stay focused on the CAPO opt-out (plus the new deprecation warning).
Adds @deprecated JSDoc markers so IDEs flag both options at their usage sites. Both still function: `legacy: true` continues to disable CAPO sorting on compat v4, and `headNext: false` still forces the legacy sort path. Runtime warnings (routed through useLogger('nuxt:unhead')) surface at build time so users have a clear migration signal before the options are removed.
…ed options The unhead module itself reads `options.legacy` and `experimental.headNext` to emit migration warnings and to compute `disableCapoSorting`. ESLint's no-deprecated rule flags these as consumer usages, so hoist them into local variables with targeted disables that explain why the read is intentional.
…mpat v4 Compat v5 gets tree-shaking via the `@unhead/vue/vite` plugin registered later in setup, but v4 users never see that plugin. Re-add the Nuxt composable tree-shaker registration gated on v4 + production so `useServerHead` / `useServerSeoMeta` / `useServerHeadSafe` still get stripped from the client bundle.
Template interpolation (%s, %siteName, %separator) in titleTemplate and SEO meta values is a core Nuxt idiom and was unhead v2 default behavior. Dropping it in the legacyPlugins refactor broke SSR for fixtures that rely on templateParams, including the basic fixture's head-script-setup page. Restore TemplateParamsPlugin unconditionally on v5 while keeping the full legacyPlugins set for v4.
|
@harlan-zw what are we waiting on for this? anything I can help with? if it helps, |
I just need to check carefully that we're not introducing behavior regressions for v4 outside of the type narrowing. I'll aim to wrap it up later this evening or tomorrow. |
WalkthroughThis pull request updates Nuxt's Unhead integration to introduce compatibility version-aware configuration and deprecate server-scoped head composables. Changes include removing explicit 🚥 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 docstrings
🧪 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/nuxt/src/head/runtime/components.ts (1)
333-340:⚠️ Potential issue | 🟡 MinorGuard
<Style>slot content with type check instead of truthiness check.The current code uses a truthiness guard followed by an unsafe
as stringcast, which allows two issues:
- Non-string values pass through to
textContentat runtime in production builds (the dev-time check is insufficient).- Empty string slots are skipped entirely, which can leave stale CSS if a slot changes from non-empty to empty.
The
input.style![idx] = styleassignment is also inside the conditional block, so it never executes for falsy or non-string content.Replace the truthiness check with a type guard to ensure only strings are assigned and the style entry is always recorded:
Suggested fix
const style = normalizeProps(props, key) as UnheadStyle const textContent = slots.default?.()?.[0]?.children - if (textContent) { - if (import.meta.dev && typeof textContent !== 'string') { + if (typeof textContent === 'string') { + style.textContent = textContent + } else if (import.meta.dev && textContent != null) { console.error('<Style> can only take a string in its default slot.') - } - input.style![idx] = style - style.textContent = textContent as string } + input.style![idx] = style update() return null🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/nuxt/src/head/runtime/components.ts` around lines 333 - 340, Replace the truthiness guard on the slot value with a proper type check: compute textContent from slots.default?.()?.[0]?.children, then always set input.style![idx] = style (so the style entry is recorded) and set style.textContent to the string only when typeof textContent === 'string'; if it's not a string set style.textContent to an empty string (or undefined) to avoid stale CSS. Keep the import.meta.dev console.error path to warn in dev when typeof textContent !== 'string'. Update the logic around normalizeProps/UnheadStyle, slots.default, input.style, style.textContent and idx accordingly.
🤖 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/schema/src/config/app.ts`:
- Around line 176-182: The resolver for the legacy config currently coerces a
user-set boolean to false when future.compatibilityVersion >= 5, which hides the
user-provided flag; update the legacy $resolve in
packages/schema/src/config/app.ts so that when typeof val === 'boolean' it
returns val (preserving the user-provided true/false) and only defaults to false
when val is not a boolean — keep the get('future.compatibilityVersion') check
out of the coercion so module.ts can detect the original user flag.
---
Outside diff comments:
In `@packages/nuxt/src/head/runtime/components.ts`:
- Around line 333-340: Replace the truthiness guard on the slot value with a
proper type check: compute textContent from slots.default?.()?.[0]?.children,
then always set input.style![idx] = style (so the style entry is recorded) and
set style.textContent to the string only when typeof textContent === 'string';
if it's not a string set style.textContent to an empty string (or undefined) to
avoid stale CSS. Keep the import.meta.dev console.error path to warn in dev when
typeof textContent !== 'string'. Update the logic around
normalizeProps/UnheadStyle, slots.default, input.style, style.textContent and
idx accordingly.
🪄 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: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: b4285ffe-efca-4603-a31a-8e5dbd1e6e50
⛔ Files ignored due to path filters (5)
package.jsonis excluded by!package.json,!**/package.jsonpackages/nitro-server/package.jsonis excluded by!**/package.jsonpackages/nuxt/package.jsonis excluded by!**/package.jsonpackages/schema/package.jsonis excluded by!**/package.jsonpnpm-lock.yamlis excluded by!**/pnpm-lock.yaml,!pnpm-lock.yaml
📒 Files selected for processing (12)
packages/nitro-server/src/runtime/handlers/island.tspackages/nitro-server/src/runtime/handlers/renderer.tspackages/nuxt/build.config.tspackages/nuxt/src/app/composables/payload.tspackages/nuxt/src/app/plugins/cross-origin-prefetch.client.tspackages/nuxt/src/head/module.tspackages/nuxt/src/head/runtime/components.tspackages/nuxt/src/head/runtime/plugins/unhead.tspackages/schema/src/config/app.tspackages/schema/src/types/schema.tstest/fixtures/basic/app/pages/head-script-setup.vuetest/nuxt/composables.test.ts
| legacy: { | ||
| $resolve: async (val, get) => { | ||
| if (typeof val === 'boolean') { | ||
| return (await get('future.compatibilityVersion') as number) >= 5 ? false : val | ||
| } | ||
| return false | ||
| }, |
There was a problem hiding this comment.
Preserve unhead.legacy so v5 users still get the warning.
Line 179 coerces unhead.legacy: true to false for compatibility v5, so packages/nuxt/src/head/module.ts Lines 105-110 cannot detect the user-provided flag and the “ignored in v5” migration warning becomes unreachable.
Proposed fix
legacy: {
- $resolve: async (val, get) => {
+ $resolve: (val) => {
if (typeof val === 'boolean') {
- return (await get('future.compatibilityVersion') as number) >= 5 ? false : val
+ return val
}
return false
},
},📝 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.
| legacy: { | |
| $resolve: async (val, get) => { | |
| if (typeof val === 'boolean') { | |
| return (await get('future.compatibilityVersion') as number) >= 5 ? false : val | |
| } | |
| return false | |
| }, | |
| legacy: { | |
| $resolve: (val) => { | |
| if (typeof val === 'boolean') { | |
| return val | |
| } | |
| return false | |
| }, | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/schema/src/config/app.ts` around lines 176 - 182, The resolver for
the legacy config currently coerces a user-set boolean to false when
future.compatibilityVersion >= 5, which hides the user-provided flag; update the
legacy $resolve in packages/schema/src/config/app.ts so that when typeof val ===
'boolean' it returns val (preserving the user-provided true/false) and only
defaults to false when val is not a boolean — keep the
get('future.compatibilityVersion') check out of the coercion so module.ts can
detect the original user flag.
🔗 Linked issue
Related to unhead v3 release (https://unhead.unjs.io/docs/releases/v3)
❓ Type of change
📚 Description
Unhead v3 is out, and we plan to have Nuxt v5 fully adopt it and all of its features (namely the Vite plugin / devtools). To make the migration smoother and to keep Nuxt using the latest (and to unblock streaming #34411), we want the next Nuxt minor to use Unhead v3.
Nuxt v4.5.0
useHead().Potential Issues
Nuxt v5
useSeoMeta->useHead) and Vite Devtools<style>or<script>tags.useServer*components are droppedMigration
For compat v4 users, no runtime code changes should be required for typical head usage. TS errors from the narrowed unions can be fixed incrementally with
defineLink/defineScript/defineMetawrappers at the call site.For compat v5 users, replace deprecated composables and patterns: