From 2bb83dd4a7b7b63bf979388473361510ac81ad5d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 6 Apr 2026 14:33:55 +1000 Subject: [PATCH 1/7] feat: add v2 migration rules to ValidatePlugin and deprecate DOM hooks - Add migration detection rules to ValidatePlugin: missing-template-params-plugin, missing-alias-sorting-plugin, deprecated-prop-children, deprecated-prop-hid-vmid, deprecated-prop-body - Mark dom:renderTag and dom:rendered hooks as @deprecated in types - Add @unhead/vue/legacy deprecation shim with re-export from client Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/unhead/src/plugins/validate.ts | 34 ++++++ packages/unhead/src/types/hooks.ts | 2 + .../unhead/test/unit/plugins/validate.test.ts | 104 +++++++++++++++++- packages/vue/build.config.ts | 1 + packages/vue/package.json | 7 ++ packages/vue/src/legacy.ts | 2 + 6 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/vue/src/legacy.ts diff --git a/packages/unhead/src/plugins/validate.ts b/packages/unhead/src/plugins/validate.ts index 28961811a..c2f952283 100644 --- a/packages/unhead/src/plugins/validate.ts +++ b/packages/unhead/src/plugins/validate.ts @@ -5,13 +5,18 @@ export type RuleSeverity = 'warn' | 'info' | 'off' export type ValidationRuleId = | 'canonical-og-url-mismatch' + | 'deprecated-prop-body' + | 'deprecated-prop-children' + | 'deprecated-prop-hid-vmid' | 'empty-meta-content' | 'empty-title' | 'html-in-title' | 'inline-script-size' | 'inline-style-size' | 'meta-beyond-1mb' + | 'missing-alias-sorting-plugin' | 'missing-description' + | 'missing-template-params-plugin' | 'missing-title' | 'non-absolute-canonical' | 'non-absolute-og-url' @@ -545,6 +550,35 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) { report('prefetch-preload-conflict', `"${tag.props.href}" has both preload and prefetch — use preload for current page resources, prefetch for future navigation.`, 'warn', tag) } + // === v2 → v3 Migration Checks === + + // Missing TemplateParamsPlugin (silent breakage — %params appear literally) + if (!head.plugins.has('template-params')) { + if (tags.some(t => t.tag === 'templateParams')) + report('missing-template-params-plugin', `templateParams are set but TemplateParamsPlugin is not registered. In v3, this plugin is opt-in. Add it to createHead({ plugins: [TemplateParamsPlugin] }).`, 'warn') + } + + // Missing AliasSortingPlugin (silent breakage — before:/after: priorities ignored) + if (!head.plugins.has('aliasSorting')) { + for (const tag of tags) { + const p = typeof tag.tagPriority === 'string' ? tag.tagPriority : '' + if (p.startsWith('before:') || p.startsWith('after:')) { + report('missing-alias-sorting-plugin', `Tag priority alias "${p}" requires AliasSortingPlugin. In v3, this plugin is opt-in. Add it to createHead({ plugins: [AliasSortingPlugin] }).`, 'warn', tag) + break + } + } + } + + // Deprecated v2 property names (no longer auto-converted) + for (const tag of tags) { + if ('children' in tag.props) + report('deprecated-prop-children', `"children" was removed in v3. Use "innerHTML" instead.`, 'warn', tag) + if ('hid' in tag.props || 'vmid' in tag.props) + report('deprecated-prop-hid-vmid', `"${('hid' in tag.props) ? 'hid' : 'vmid'}" was removed in v3. Use "key" instead.`, 'warn', tag) + if ('body' in tag.props) + report('deprecated-prop-body', `"body: true" was removed in v3. Use "tagPosition: 'bodyClose'" instead.`, 'warn', tag) + } + // Meta tags rendered after the crawler byte limit (default 1MB) // Social crawlers (Facebook, Twitter) only parse the first ~1MB of HTML. // Large inline styles can push SEO/OG meta tags past this limit. diff --git a/packages/unhead/src/types/hooks.ts b/packages/unhead/src/types/hooks.ts index ce142fad2..9f77150d8 100644 --- a/packages/unhead/src/types/hooks.ts +++ b/packages/unhead/src/types/hooks.ts @@ -47,7 +47,9 @@ export interface CoreHeadHooks { export interface DOMHeadHooks { 'dom:beforeRender': (ctx: DomBeforeRenderCtx) => SyncHookResult + /** @deprecated This hook is not called internally and will be removed in v4. */ 'dom:renderTag': (ctx: DomRenderTagContext, document: Document, track: (id: string, scope: string, fn: () => void) => void) => HookResult + /** @deprecated Will be removed in v4. DOM rendering is now synchronous — run post-render logic after calling `renderDOMHead()` directly. */ 'dom:rendered': (ctx: { renders: DomRenderTagContext[] }) => HookResult } diff --git a/packages/unhead/test/unit/plugins/validate.test.ts b/packages/unhead/test/unit/plugins/validate.test.ts index 1acbd8e84..71203388a 100644 --- a/packages/unhead/test/unit/plugins/validate.test.ts +++ b/packages/unhead/test/unit/plugins/validate.test.ts @@ -1,7 +1,7 @@ import type { HeadValidationRule, ValidatePluginOptions } from '../../../src/plugins' import { renderSSRHead } from '@unhead/ssr' import { describe, expect, it, vi } from 'vitest' -import { ValidatePlugin } from '../../../src/plugins' +import { AliasSortingPlugin, TemplateParamsPlugin, ValidatePlugin } from '../../../src/plugins' import { createHead } from '../../../src/server' function createValidationHead(opts?: Pick) { @@ -869,4 +869,106 @@ describe('validatePlugin', () => { expect(rules.find(r => r.id === 'meta-beyond-1mb')).toBeFalsy() }) }) + + describe('v2 migration', () => { + it('warns when templateParams used without TemplateParamsPlugin', () => { + const { head, rules } = createValidationHead() + head.push({ + templateParams: { site: { name: 'My Site' }, separator: '|' }, + title: 'Hello', + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'missing-template-params-plugin')).toBeTruthy() + }) + + it('does not warn about templateParams when TemplateParamsPlugin is registered', () => { + const results: HeadValidationRule[] = [] + const head = createHead({ + disableDefaults: true, + plugins: [ + TemplateParamsPlugin, + ValidatePlugin({ onReport: r => results.push(...r) }), + ], + }) + head.push({ + templateParams: { site: { name: 'My Site' }, separator: '|' }, + title: 'Hello', + } as any) + renderSSRHead(head) + expect(results.find(r => r.id === 'missing-template-params-plugin')).toBeFalsy() + }) + + it('warns when before:/after: tagPriority used without AliasSortingPlugin', () => { + const { head, rules } = createValidationHead() + head.push({ + script: [{ src: '/a.js', tagPriority: 'before:script:key:b' }], + }) + renderSSRHead(head) + expect(rules.find(r => r.id === 'missing-alias-sorting-plugin')).toBeTruthy() + }) + + it('does not warn about alias sorting when AliasSortingPlugin is registered', () => { + const results: HeadValidationRule[] = [] + const head = createHead({ + disableDefaults: true, + plugins: [ + AliasSortingPlugin, + ValidatePlugin({ onReport: r => results.push(...r) }), + ], + }) + head.push({ + script: [{ src: '/a.js', tagPriority: 'before:script:key:b' }], + }) + renderSSRHead(head) + expect(results.find(r => r.id === 'missing-alias-sorting-plugin')).toBeFalsy() + }) + + it('warns on deprecated "children" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + script: [{ children: 'console.log("hello")' }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-children')).toBeTruthy() + }) + + it('warns on deprecated "hid" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + meta: [{ hid: 'description', name: 'description', content: 'test' }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-hid-vmid')).toBeTruthy() + }) + + it('warns on deprecated "vmid" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + meta: [{ vmid: 'description', name: 'description', content: 'test' }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-hid-vmid')).toBeTruthy() + }) + + it('warns on deprecated "body" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + script: [{ src: '/script.js', body: true }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-body')).toBeTruthy() + }) + + it('respects rule severity config for migration rules', () => { + const { head, rules } = createValidationHead({ + rules: { 'missing-template-params-plugin': 'off' }, + }) + head.push({ + templateParams: { site: { name: 'My Site' } }, + title: 'Hello', + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'missing-template-params-plugin')).toBeFalsy() + }) + }) }) diff --git a/packages/vue/build.config.ts b/packages/vue/build.config.ts index 5b9698977..cf987c3a9 100644 --- a/packages/vue/build.config.ts +++ b/packages/vue/build.config.ts @@ -16,6 +16,7 @@ export default defineBuildConfig({ { input: 'src/utils', name: 'utils' }, { input: 'src/stream/vite', name: 'stream/vite' }, { input: 'src/stream/iife', name: 'stream/iife' }, + { input: 'src/legacy', name: 'legacy' }, ], hooks: { 'rollup:options': (_, options) => { diff --git a/packages/vue/package.json b/packages/vue/package.json index a2ee51e21..8bde2b19e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -68,6 +68,10 @@ "./stream/iife": { "types": "./dist/stream/iife.d.ts", "default": "./dist/stream/iife.mjs" + }, + "./legacy": { + "types": "./dist/legacy.d.ts", + "default": "./dist/legacy.mjs" } }, "main": "dist/index.mjs", @@ -107,6 +111,9 @@ ], "stream/iife": [ "dist/stream/iife" + ], + "legacy": [ + "dist/legacy" ] } }, diff --git a/packages/vue/src/legacy.ts b/packages/vue/src/legacy.ts new file mode 100644 index 000000000..de1590f60 --- /dev/null +++ b/packages/vue/src/legacy.ts @@ -0,0 +1,2 @@ +console.warn('[unhead] `@unhead/vue/legacy` is deprecated. Import from `@unhead/vue/client` or `@unhead/vue/server` instead.') +export * from './client' From 072385e9a9ebebc38de087f1648d85e0ede21375 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 6 Apr 2026 14:37:50 +1000 Subject: [PATCH 2/7] fix: add explicit type annotation for strict TypeScript check Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/unhead/src/plugins/validate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/validate.ts b/packages/unhead/src/plugins/validate.ts index c2f952283..fcacf0550 100644 --- a/packages/unhead/src/plugins/validate.ts +++ b/packages/unhead/src/plugins/validate.ts @@ -554,7 +554,7 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) { // Missing TemplateParamsPlugin (silent breakage — %params appear literally) if (!head.plugins.has('template-params')) { - if (tags.some(t => t.tag === 'templateParams')) + if (tags.some((t: HeadTag) => t.tag === 'templateParams')) report('missing-template-params-plugin', `templateParams are set but TemplateParamsPlugin is not registered. In v3, this plugin is opt-in. Add it to createHead({ plugins: [TemplateParamsPlugin] }).`, 'warn') } From 9549a2073e22d8802ab0d6158c24f42d2cbd2310 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 6 Apr 2026 14:42:41 +1000 Subject: [PATCH 3/7] fix: update export snapshot for @unhead/vue/legacy Co-Authored-By: Claude Opus 4.6 (1M context) --- test/exports/vue.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/exports/vue.yaml b/test/exports/vue.yaml index c47db7273..6f124635a 100644 --- a/test/exports/vue.yaml +++ b/test/exports/vue.yaml @@ -14,6 +14,10 @@ VueHeadMixin: object ./components: Head: object +./legacy: + createHead: function + renderDOMHead: function + VueHeadMixin: object ./plugins: AliasSortingPlugin: object CanonicalPlugin: function From de8e14c0e3e30deb29ac39e4ffe512d26c63e106 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 6 Apr 2026 15:50:15 +1000 Subject: [PATCH 4/7] fix: narrow body deprecation check to body: true only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review — `'body' in tag.props` triggered false positives for `body: false`. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/unhead/src/plugins/validate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/validate.ts b/packages/unhead/src/plugins/validate.ts index fcacf0550..4877a89f4 100644 --- a/packages/unhead/src/plugins/validate.ts +++ b/packages/unhead/src/plugins/validate.ts @@ -575,7 +575,7 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) { report('deprecated-prop-children', `"children" was removed in v3. Use "innerHTML" instead.`, 'warn', tag) if ('hid' in tag.props || 'vmid' in tag.props) report('deprecated-prop-hid-vmid', `"${('hid' in tag.props) ? 'hid' : 'vmid'}" was removed in v3. Use "key" instead.`, 'warn', tag) - if ('body' in tag.props) + if (tag.props.body === true) report('deprecated-prop-body', `"body: true" was removed in v3. Use "tagPosition: 'bodyClose'" instead.`, 'warn', tag) } From 2ebc6921357a3124d18ebf0cf75ae69b88205558 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 6 Apr 2026 15:59:47 +1000 Subject: [PATCH 5/7] docs: add ValidatePlugin migration section to MIGRATION.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recommends using ValidatePlugin during v2→v3 upgrade to detect deprecated props, missing TemplateParamsPlugin/AliasSortingPlugin, and other common migration issues. Co-Authored-By: Claude Opus 4.6 (1M context) --- MIGRATION.md | 470 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..cd1d3cf98 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,470 @@ +# Migration Guide: Unhead v3 + +This guide helps you migrate from deprecated APIs to their modern replacements in Unhead v3. Sections are ordered by likelihood of affecting your application. + +## Automated Migration Checks + +Add `ValidatePlugin` during your upgrade to automatically detect v2 patterns and get actionable warnings: + +```ts +import { ValidatePlugin } from 'unhead/plugins' + +const head = createHead({ + plugins: [ + ValidatePlugin() // Detects deprecated props, missing plugins, and more + ] +}) +``` + +The plugin will warn you about: +- **Missing `TemplateParamsPlugin`** — template params like `%siteName` are now opt-in and will appear literally without the plugin +- **Missing `AliasSortingPlugin`** — `before:`/`after:` tag priorities are now opt-in and will be silently ignored without the plugin +- **Deprecated property names** — `children`, `hid`, `vmid`, `body: true` are no longer auto-converted + +All rules use ESLint-style config and can be individually disabled: + +```ts +ValidatePlugin({ + rules: { + 'missing-template-params-plugin': 'off', + } +}) +``` + +Remove `ValidatePlugin` once your migration is complete, or keep it for ongoing validation. + +--- + +## Table of Contents + +1. [Legacy Property Names](#1-legacy-property-names) - High Impact +2. [Schema.org Plugin](#2-schema-org-plugin) - High Impact +3. [Server Composables](#3-server-composables) - Medium-High Impact +4. [Vue Legacy Exports](#4-vue-legacy-exports) - Medium Impact +5. [Core API Changes](#5-core-api-changes) - Medium Impact +6. [Schema.org Config Options](#6-schema-org-config-options) - Low-Medium Impact +7. [Server Utilities](#7-server-utilities) - Low Impact +8. [Type Changes](#8-type-changes) - Low Impact +9. [Hooks](#9-hooks) - Low Impact +10. [Other Removed APIs](#10-other-removed-apis) - Low Impact + +--- + +## 1. Legacy Property Names + +**Impact: High** - Many existing codebases use these legacy property names. + +The `DeprecationsPlugin` that automatically converted legacy property names has been removed. You must update your head entries to use the current property names. + +### `children` → `innerHTML` + +```diff +head.push({ + script: [{ +- children: 'console.log("hello")', ++ innerHTML: 'console.log("hello")', + }] +}) +``` + +### `hid` / `vmid` → `key` + +```diff +head.push({ + meta: [{ +- hid: 'description', ++ key: 'description', + name: 'description', + content: 'My description' + }] +}) +``` + +```diff +head.push({ + meta: [{ +- vmid: 'og:title', ++ key: 'og:title', + property: 'og:title', + content: 'My Title' + }] +}) +``` + +### `body: true` → `tagPosition: 'bodyClose'` + +```diff +head.push({ + script: [{ + src: '/script.js', +- body: true, ++ tagPosition: 'bodyClose', + }] +}) +``` + +### Quick Reference + +| Old Property | New Property | +|-------------|--------------| +| `children` | `innerHTML` | +| `hid` | `key` | +| `vmid` | `key` | +| `body: true` | `tagPosition: 'bodyClose'` | + +--- + +## 2. Schema.org Plugin + +**Impact: High** - Anyone using `@unhead/schema-org` will need to update. + +The `PluginSchemaOrg` and `SchemaOrgUnheadPlugin` exports have been removed. Use `UnheadSchemaOrg` instead. + +```diff +- import { PluginSchemaOrg } from '@unhead/schema-org' ++ import { UnheadSchemaOrg } from '@unhead/schema-org' + +const head = createHead({ + plugins: [ +- PluginSchemaOrg() ++ UnheadSchemaOrg() + ] +}) +``` + +```diff +- import { SchemaOrgUnheadPlugin } from '@unhead/schema-org' ++ import { UnheadSchemaOrg } from '@unhead/schema-org' + +const head = createHead({ + plugins: [ +- SchemaOrgUnheadPlugin() ++ UnheadSchemaOrg() + ] +}) +``` + +For Vue users: + +```diff +- import { PluginSchemaOrg } from '@unhead/schema-org/vue' ++ import { UnheadSchemaOrg } from '@unhead/schema-org/vue' +``` + +--- + +## 3. Server Composables + +**Impact: Medium-High** - Common in SSR applications. + +The `useServerHead`, `useServerHeadSafe`, and `useServerSeoMeta` composables have been removed. Use the standard composables instead. + +### `useServerHead` → `useHead` + +```diff +- import { useServerHead } from 'unhead' ++ import { useHead } from 'unhead' + +- useServerHead({ title: 'My Page' }) ++ useHead({ title: 'My Page' }) +``` + +### `useServerHeadSafe` → `useHeadSafe` + +```diff +- import { useServerHeadSafe } from 'unhead' ++ import { useHeadSafe } from 'unhead' + +- useServerHeadSafe({ title: userInput }) ++ useHeadSafe({ title: userInput }) +``` + +### `useServerSeoMeta` → `useSeoMeta` + +```diff +- import { useServerSeoMeta } from 'unhead' ++ import { useSeoMeta } from 'unhead' + +- useServerSeoMeta({ description: 'My description' }) ++ useSeoMeta({ description: 'My description' }) +``` + +**Note:** If you need server-only head management, use conditional logic or framework-specific SSR detection instead of mode-based composables. + +--- + +## 4. Vue Legacy Exports + +**Impact: Medium** - Affects Vue users on older setups. + +### `/legacy` Export Path Removed + +The `/legacy` export path has been removed from `@unhead/vue`. + +```diff +- import { createHead } from '@unhead/vue/legacy' ++ import { createHead } from '@unhead/vue/client' +// or for SSR ++ import { createHead } from '@unhead/vue/server' +``` + +### `createHeadCore` Removed + +```diff +- import { createHeadCore } from '@unhead/vue' ++ import { createHead } from '@unhead/vue/server' +// or for client ++ import { createHead } from '@unhead/vue/client' +``` + +--- + +## 5. Core API Changes + +**Impact: Medium** - Affects custom integrations and advanced usage. + +### `createHeadCore` → `createUnhead` + +```diff +- import { createHeadCore } from 'unhead' ++ import { createUnhead } from 'unhead' + +- const head = createHeadCore() ++ const head = createUnhead() +``` + +### `headEntries()` → `entries` Map + +The `headEntries()` method has been removed. Access entries directly via the `entries` Map. + +```diff +- const entries = head.headEntries() ++ const entries = [...head.entries.values()] +``` + +### `mode` Option Removed + +The `mode` option on head entries has been removed. Runtime mode detection is no longer supported. + +```diff +head.push({ + title: 'My Page', +- }, { mode: 'server' }) ++ }) +``` + +If you need server-only or client-only head management, use the appropriate `createHead` function: + +```ts +// Client-side +import { createHead } from 'unhead/client' + +// Server-side +import { createHead } from 'unhead/server' +``` + +--- + +## 6. Schema.org Config Options + +**Impact: Low-Medium** - Affects users with custom Schema.org configuration. + +The following Schema.org config options have been removed: + +| Removed Option | Replacement | +|---------------|-------------| +| `canonicalHost` | `host` | +| `canonicalUrl` | `path` + `host` | +| `position` | Use `tagPosition` on individual schema entries | +| `defaultLanguage` | Use `inLanguage` on schema nodes | +| `defaultCurrency` | Use `priceCurrency` on schema nodes | + +```diff +UnheadSchemaOrg({ +- canonicalHost: 'https://example.com', +- canonicalUrl: 'https://example.com/page', ++ host: 'https://example.com', ++ path: '/page', +}) +``` + +--- + +## 7. Server Utilities + +**Impact: Low** - Only affects users parsing HTML for head extraction. + +### `extractUnheadInputFromHtml` → `parseHtmlForUnheadExtraction` + +The function has been moved from `unhead/server` to `unhead/parser`. + +```diff +- import { extractUnheadInputFromHtml } from 'unhead/server' ++ import { parseHtmlForUnheadExtraction } from 'unhead/parser' + +- const { input } = extractUnheadInputFromHtml(html) ++ const { input } = parseHtmlForUnheadExtraction(html) +``` + +--- + +## 8. Type Changes + +**Impact: Low** - Only affects TypeScript users with explicit type imports. + +### Removed Type Aliases + +| Removed Type | Replacement | +|-------------|-------------| +| `Head` | `HeadTag` or specific tag types | +| `ResolvedHead` | `ResolvedHeadTag` | +| `MergeHead` | Use generics directly | +| `MetaFlatInput` | `MetaFlat` | +| `ResolvedMetaFlat` | `MetaFlat` | +| `RuntimeMode` | Removed (no replacement needed) | + +```diff +- import type { Head, MetaFlatInput, RuntimeMode } from 'unhead' ++ import type { HeadTag, MetaFlat } from 'unhead' +``` + +--- + +## 9. Hooks + +**Impact: Low** - Only affects users with custom hook implementations. + +### `init` Hook Removed + +The `init` hook has been removed from `HeadHooks`. + +```diff +- head.hooks.hook('init', (ctx) => { +- // Initialize something +- }) +``` + +### `dom:renderTag` Hook Removed + +The `dom:renderTag` hook has been removed. This hook was called for each tag during DOM rendering. + +```diff +- head.hooks.hook('dom:renderTag', (ctx, document, track) => { +- // Custom tag rendering logic +- }) +``` + +### `dom:rendered` Hook Removed + +The `dom:rendered` hook has been removed. DOM rendering is now synchronous. + +```diff +- head.hooks.hook('dom:rendered', ({ renders }) => { +- // Post-render logic +- }) +``` + +If you need to run logic after DOM rendering, call `renderDOMHead` directly and add your logic after: + +```ts +import { renderDOMHead } from 'unhead/client' + +renderDOMHead(head) +// Your post-render logic here +``` + +### `dom:beforeRender` is Now Synchronous + +The `dom:beforeRender` hook no longer supports async handlers. + +```diff +head.hooks.hook('dom:beforeRender', (ctx) => { +- await someAsyncOperation() + ctx.shouldRender = true +}) +``` + +### `renderDOMHead` is Now Synchronous + +The `renderDOMHead` function no longer returns a Promise. + +```diff +- await renderDOMHead(head, { document }) ++ renderDOMHead(head, { document }) +``` + +--- + +## 10. Other Removed APIs + +**Impact: Low** - Internal utilities rarely used directly. + +### `resolveScriptKey` + +The `resolveScriptKey` function is no longer exported. This was an internal utility. + +### `resolveUnrefHeadInput` (Vue) + +```diff +- import { resolveUnrefHeadInput } from '@unhead/vue' +``` + +Reactive head input resolution now happens automatically within the head manager. + +### `setHeadInjectionHandler` (Vue) + +```diff +- import { setHeadInjectionHandler } from '@unhead/vue' +- setHeadInjectionHandler(() => myHead) +``` + +Head injection is now handled automatically. + +### `DeprecationsPlugin` + +```diff +- import { DeprecationsPlugin } from 'unhead/plugins' +``` + +Instead of using this plugin, update your code to use the current property names (see [Section 1](#1-legacy-property-names)). + +--- + +## Quick Reference: Import Changes + +```diff +// Legacy properties - update property names directly, no plugin needed +- import { DeprecationsPlugin } from 'unhead/plugins' + +// Schema.org +- import { PluginSchemaOrg, SchemaOrgUnheadPlugin } from '@unhead/schema-org' ++ import { UnheadSchemaOrg } from '@unhead/schema-org' + +// Server composables +- import { useServerHead, useServerHeadSafe, useServerSeoMeta } from 'unhead' ++ import { useHead, useHeadSafe, useSeoMeta } from 'unhead' + +// Core +- import { createHeadCore } from 'unhead' ++ import { createUnhead } from 'unhead' + +// Server utilities +- import { extractUnheadInputFromHtml } from 'unhead/server' ++ import { parseHtmlForUnheadExtraction } from 'unhead/parser' + +// Vue +- import { createHeadCore, resolveUnrefHeadInput, setHeadInjectionHandler } from '@unhead/vue' +- import { ... } from '@unhead/vue/legacy' ++ import { createHead } from '@unhead/vue/client' ++ import { createHead } from '@unhead/vue/server' +``` + +--- + +## Need Help? + +If you encounter issues during migration: + +1. Check the [documentation](https://unhead.unjs.io) +2. Search [existing issues](https://github.com/unjs/unhead/issues) +3. Open a [new issue](https://github.com/unjs/unhead/issues/new) if needed From dc6aa7ee2025dacc2f97b2fb997b2b0dc95dc3de Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 7 Apr 2026 13:45:22 +1000 Subject: [PATCH 6/7] fix: add deprecated-option-mode rule, fix MIGRATION.md accuracy - Detect `mode` option on head.push() (silently ignored in v3) - Fix MIGRATION.md: /legacy is deprecated not removed - Fix Schema.org anchor links in TOC - Fix dom:rendered @deprecated JSDoc wording --- MIGRATION.md | 8 +++--- packages/unhead/src/plugins/validate.ts | 4 +++ packages/unhead/src/types/hooks.ts | 4 +-- .../unhead/test/unit/plugins/validate.test.ts | 27 +++++++++++++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index cd1d3cf98..50ce5ce4b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -38,11 +38,11 @@ Remove `ValidatePlugin` once your migration is complete, or keep it for ongoing ## Table of Contents 1. [Legacy Property Names](#1-legacy-property-names) - High Impact -2. [Schema.org Plugin](#2-schema-org-plugin) - High Impact +2. [Schema.org Plugin](#2-schemaorg-plugin) - High Impact 3. [Server Composables](#3-server-composables) - Medium-High Impact 4. [Vue Legacy Exports](#4-vue-legacy-exports) - Medium Impact 5. [Core API Changes](#5-core-api-changes) - Medium Impact -6. [Schema.org Config Options](#6-schema-org-config-options) - Low-Medium Impact +6. [Schema.org Config Options](#6-schemaorg-config-options) - Low-Medium Impact 7. [Server Utilities](#7-server-utilities) - Low Impact 8. [Type Changes](#8-type-changes) - Low Impact 9. [Hooks](#9-hooks) - Low Impact @@ -197,9 +197,9 @@ The `useServerHead`, `useServerHeadSafe`, and `useServerSeoMeta` composables hav **Impact: Medium** - Affects Vue users on older setups. -### `/legacy` Export Path Removed +### `/legacy` Export Path Deprecated -The `/legacy` export path has been removed from `@unhead/vue`. +The `@unhead/vue/legacy` import still works but emits a runtime deprecation warning. Update to the explicit client or server import: ```diff - import { createHead } from '@unhead/vue/legacy' diff --git a/packages/unhead/src/plugins/validate.ts b/packages/unhead/src/plugins/validate.ts index 4877a89f4..6221307dd 100644 --- a/packages/unhead/src/plugins/validate.ts +++ b/packages/unhead/src/plugins/validate.ts @@ -5,6 +5,7 @@ export type RuleSeverity = 'warn' | 'info' | 'off' export type ValidationRuleId = | 'canonical-og-url-mismatch' + | 'deprecated-option-mode' | 'deprecated-prop-body' | 'deprecated-prop-children' | 'deprecated-prop-hid-vmid' @@ -289,6 +290,9 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) { return defineHeadPlugin((head: Unhead) => { const _push = head.push.bind(head) head.push = (input, opts) => { + if ((opts as any)?.mode && resolveSeverity(ruleConfig['deprecated-option-mode'] as RuleSeverity | [RuleSeverity, unknown] | undefined, 'warn') !== 'off') { + console.warn(`[unhead] "mode: '${(opts as any).mode}'" option was removed in v3. Use the appropriate createHead import (unhead/client or unhead/server) instead.`) + } const source = captureSource(root) const active = _push(input, opts) if (source) diff --git a/packages/unhead/src/types/hooks.ts b/packages/unhead/src/types/hooks.ts index 9f77150d8..ff4b913f9 100644 --- a/packages/unhead/src/types/hooks.ts +++ b/packages/unhead/src/types/hooks.ts @@ -47,9 +47,9 @@ export interface CoreHeadHooks { export interface DOMHeadHooks { 'dom:beforeRender': (ctx: DomBeforeRenderCtx) => SyncHookResult - /** @deprecated This hook is not called internally and will be removed in v4. */ + /** @deprecated Not called internally. Will be removed in v4. */ 'dom:renderTag': (ctx: DomRenderTagContext, document: Document, track: (id: string, scope: string, fn: () => void) => void) => HookResult - /** @deprecated Will be removed in v4. DOM rendering is now synchronous — run post-render logic after calling `renderDOMHead()` directly. */ + /** @deprecated Will be removed in v4. DOM rendering is synchronous; run post-render logic after calling `renderDOMHead()` directly. */ 'dom:rendered': (ctx: { renders: DomRenderTagContext[] }) => HookResult } diff --git a/packages/unhead/test/unit/plugins/validate.test.ts b/packages/unhead/test/unit/plugins/validate.test.ts index 71203388a..c2471b94f 100644 --- a/packages/unhead/test/unit/plugins/validate.test.ts +++ b/packages/unhead/test/unit/plugins/validate.test.ts @@ -970,5 +970,32 @@ describe('validatePlugin', () => { renderSSRHead(head) expect(rules.find(r => r.id === 'missing-template-params-plugin')).toBeFalsy() }) + + it('warns on deprecated "mode" option in head.push()', () => { + const { head } = createValidationHead() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + head.push({ title: 'Test' }, { mode: 'server' } as any) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"mode: \'server\'" option was removed in v3'), + ) + warnSpy.mockRestore() + }) + + it('does not warn on "mode" option when rule is off', () => { + const results: HeadValidationRule[] = [] + const head = createHead({ + disableDefaults: true, + plugins: [ + ValidatePlugin({ + onReport: r => results.push(...r), + rules: { 'deprecated-option-mode': 'off' }, + }), + ], + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + head.push({ title: 'Test' }, { mode: 'server' } as any) + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) }) }) From 342c962cecf9c0700e1845803ebdeec608108411 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 7 Apr 2026 14:29:35 +1000 Subject: [PATCH 7/7] chore: sync --- .../head/guides/0.get-started/1.migration.md | 355 ------------------ .../content/6.migration-guide/1.v3.md | 317 +++++----------- docs/content/6.migration-guide/2.v2.md | 202 ++++++++++ .../v3.md => content/7.releases/1.v3.md} | 65 +--- docs/content/7.releases/2.v2.md | 189 ++++++++++ 5 files changed, 509 insertions(+), 619 deletions(-) delete mode 100644 docs/0.typescript/head/guides/0.get-started/1.migration.md rename MIGRATION.md => docs/content/6.migration-guide/1.v3.md (52%) create mode 100644 docs/content/6.migration-guide/2.v2.md rename docs/{head/1.guides/releases/v3.md => content/7.releases/1.v3.md} (84%) create mode 100644 docs/content/7.releases/2.v2.md diff --git a/docs/0.typescript/head/guides/0.get-started/1.migration.md b/docs/0.typescript/head/guides/0.get-started/1.migration.md deleted file mode 100644 index bf2b45a96..000000000 --- a/docs/0.typescript/head/guides/0.get-started/1.migration.md +++ /dev/null @@ -1,355 +0,0 @@ ---- -title: Upgrade Guide -description: Learn how to migrate between Unhead versions for TypeScript users. -navigation: - title: Upgrade Guide ---- - -## Migrate to v3 (from v2) - -Unhead v3 removes all deprecated APIs and focuses on performance improvements. This guide covers the breaking changes. - -### Legacy Property Names - -🚦 Impact Level: High - -The `DeprecationsPlugin` that automatically converted legacy property names has been removed. You must update your head entries to use the current property names. - -**`children` → `innerHTML`** - -```diff -useHead({ - script: [{ -- children: 'console.log("hello")', -+ innerHTML: 'console.log("hello")', - }] -}) -``` - -**`hid` / `vmid` → `key`** - -```diff -useHead({ - meta: [{ -- hid: 'description', -+ key: 'description', - name: 'description', - content: 'My description' - }] -}) -``` - -**`body: true` → `tagPosition: 'bodyClose'`** - -```diff -useHead({ - script: [{ - src: '/script.js', -- body: true, -+ tagPosition: 'bodyClose', - }] -}) -``` - -### Schema.org Plugin - -🚦 Impact Level: High - -The `PluginSchemaOrg` and `SchemaOrgUnheadPlugin` exports have been removed. Use `UnheadSchemaOrg` instead. - -```diff -- import { PluginSchemaOrg } from '@unhead/schema-org' -+ import { UnheadSchemaOrg } from '@unhead/schema-org' - -const head = createHead({ - plugins: [ -- PluginSchemaOrg() -+ UnheadSchemaOrg() - ] -}) -``` - -### Server Composables Removed - -🚦 Impact Level: Medium-High - -The `useServerHead`, `useServerHeadSafe`, and `useServerSeoMeta` composables have been removed. Use the standard composables instead. - -```diff -- import { useServerHead, useServerSeoMeta } from 'unhead' -+ import { useHead, useSeoMeta } from 'unhead' - -- useServerHead({ title: 'My Page' }) -+ useHead({ title: 'My Page' }) - -- useServerSeoMeta({ description: 'My description' }) -+ useSeoMeta({ description: 'My description' }) -``` - -If you need server-only head management, use conditional logic: - -```ts -if (import.meta.server) { - useHead({ title: 'Server Only' }) -} -``` - -### Core API Changes - -🚦 Impact Level: Medium - -**`createHeadCore` → `createUnhead`** - -```diff -- import { createHeadCore } from 'unhead' -+ import { createUnhead } from 'unhead' - -- const head = createHeadCore() -+ const head = createUnhead() -``` - -**`headEntries()` → `entries` Map** - -```diff -- const entries = head.headEntries() -+ const entries = [...head.entries.values()] -``` - -**`mode` Option Removed** - -The `mode` option on head entries has been removed. - -```diff -head.push({ - title: 'My Page', -- }, { mode: 'server' }) -+ }) -``` - -### Hooks - -🚦 Impact Level: Low - -The following hooks have been removed: - -- `init` - No longer needed -- `dom:renderTag` - DOM rendering is now synchronous -- `dom:rendered` - Use the `onRendered` option on `useHead()` instead - -The `dom:beforeRender` hook is now synchronous and `renderDOMHead` no longer returns a Promise: - -```diff -- await renderDOMHead(head, { document }) -+ renderDOMHead(head, { document }) -``` - -The SSR hooks (`ssr:beforeRender`, `ssr:render`, `ssr:rendered`) are now synchronous and `renderSSRHead` no longer returns a Promise: - -```diff -- const head = await renderSSRHead(head) -+ const head = renderSSRHead(head) -``` - -### Type Changes - -🚦 Impact Level: Low - -| Removed Type | Replacement | -|-------------|-------------| -| `Head` | `HeadTag` or specific tag types | -| `ResolvedHead` | `ResolvedHeadTag` | -| `MetaFlatInput` | `MetaFlat` | -| `RuntimeMode` | Removed (no replacement needed) | - -```diff -- import type { Head, MetaFlatInput, RuntimeMode } from 'unhead' -+ import type { HeadTag, MetaFlat } from 'unhead' -``` - -### Other Removed APIs - -- `resolveScriptKey` - Internal utility, no longer exported -- `DeprecationsPlugin` - Update property names directly instead -- `extractUnheadInputFromHtml` - Use `parseHtmlForUnheadExtraction` from `unhead/parser` - ---- - -## Migrate to v2 (from v1) - -With the release of Unhead v2, we now have first-class support for other frameworks. This section covers the v1 to v2 migration. - -### Client / Server Subpath Exports - -🚦 Impact Level: Critical - -**⚠️ Breaking Changes:** - -- `createServerHead()`{lang="ts"} and `createHead()`{lang="ts"} exports from `unhead` are removed - -The path where you import `createHead` from has been updated to be a subpath export. - -**Client bundle:** - -```diff --import { createServerHead } from 'unhead' -+import { createHead } from 'unhead/client' - -// avoids bundling server plugins -createHead() -``` - -**Server bundle:** - -```diff --import { createServerHead } from 'unhead' -+import { createHead } from 'unhead/server' - -// avoids bundling server plugins --createServerHead() -+createHead() -``` - -### Removed Implicit Context - -🚦 Impact Level: Critical - -**⚠️ Breaking Changes:** - -- `getActiveHead()`{lang="ts"}, `activeHead`{lang="ts"} exports are removed - -The implicit context implementation kept a global instance of Unhead available so that you could use the `useHead()`{lang="ts"} composables anywhere in your application. - -In v2, the core composables no longer have access to the Unhead instance. Instead, you must pass the Unhead instance to the composables. - -::note -Passing the instance is only relevant if you're importing from `unhead`. In JavaScript frameworks we tie the context to the framework itself so you don't need to worry about this. -:: - -```ts [TypeScript v2] -import { useHead } from 'unhead' - -// example of getting the instance -const unheadInstance = useMyApp().unhead -useHead(unheadInstance, { - title: 'Looks good' -}) -``` - -### Removed `vmid`, `hid`, `children`, `body` - -🚦 Impact Level: High - -For legacy support with Vue Meta we allowed end users to provide deprecated properties: `vmid`, `hid`, `children` and `body`. - -You must update these properties to the appropriate replacement or remove them. See the [v3 migration section](#legacy-property-names) for the replacements. - -### Opt-in Template Params & Tag Alias Sorting - -🚦 Impact Level: High - -To reduce the bundle size and improve performance, we've moved the template params and tag alias sorting to optional plugins. - -```ts -import { AliasSortingPlugin, TemplateParamsPlugin } from 'unhead/plugins' - -createHead({ - plugins: [TemplateParamsPlugin, AliasSortingPlugin] -}) -``` - -### Promise Input Support - -🚦 Impact Level: Medium - -If you have promises as input they will no longer be resolved, either await the promise before passing it along or register the optional promises plugin. - -```ts -import { PromisePlugin } from 'unhead/plugins' - -const unhead = createHead({ - plugins: [PromisePlugin] -}) -``` - -### Updated `useScript()`{lang="ts"} - -🚦 Impact Level: High - -**⚠️ Breaking Changes:** - -- Script instance is no longer augmented as a proxy and promise -- `script.proxy`{lang="ts"} is rewritten for simpler, more stable behavior -- `stub()`{lang="ts"} and runtime hook `script:instance-fn` are removed - -**Replacing promise usage** - -```diff -const script = useScript() - --script.then(() => console.log('loaded') -+script.onLoaded(() => console.log('loaded')) -``` - -**Replacing proxy usage** - -```diff -const script = useScript('..', { - use() { return { foo: [] } } -}) - --script.foo.push('bar') -+script.proxy.foo.push('bar') -``` - -### Tag Sorting Updated - -🚦 Impact Level: Low - -[Capo.js](https://rviscomi.github.io/capo.js/) sorting is now the default. You can opt-out: - -```ts -createHead({ - disableCapoSorting: true, -}) -``` - -### Default SSR Tags - -🚦 Impact Level: Low - -When SSR Unhead will now insert important default tags for you: - -- `` -- `` -- `` - -```ts -import { createHead } from 'unhead/server' - -// disable when creating the head instance -const head = createHead({ - disableDefaults: true, -}) -``` - -### CJS Exports Removed - -🚦 Impact Level: Low - -CommonJS exports have been removed in favor of ESM only. - -```diff --const { createHead } = require('unhead/client') -+import { createHead } from 'unhead/client' -``` - -### Deprecated `@unhead/schema` - -🚦 Impact Level: Low - -The `@unhead/schema` package is deprecated. Import from `unhead/types` or `unhead` instead. - -```diff --import { HeadTag } from '@unhead/schema' -+import { HeadTag } from 'unhead/types' -``` diff --git a/MIGRATION.md b/docs/content/6.migration-guide/1.v3.md similarity index 52% rename from MIGRATION.md rename to docs/content/6.migration-guide/1.v3.md index 50ce5ce4b..0195366cc 100644 --- a/MIGRATION.md +++ b/docs/content/6.migration-guide/1.v3.md @@ -1,6 +1,11 @@ -# Migration Guide: Unhead v3 +--- +title: "Migrate to v3" +description: "Migrate from Unhead v2 to v3. Covers all breaking changes, removed APIs, and their replacements." +navigation: + title: "v3" +--- -This guide helps you migrate from deprecated APIs to their modern replacements in Unhead v3. Sections are ordered by likelihood of affecting your application. +Unhead v3 removes all deprecated APIs and focuses on performance improvements. This guide covers the breaking changes. ## Automated Migration Checks @@ -17,9 +22,10 @@ const head = createHead({ ``` The plugin will warn you about: -- **Missing `TemplateParamsPlugin`** — template params like `%siteName` are now opt-in and will appear literally without the plugin -- **Missing `AliasSortingPlugin`** — `before:`/`after:` tag priorities are now opt-in and will be silently ignored without the plugin -- **Deprecated property names** — `children`, `hid`, `vmid`, `body: true` are no longer auto-converted +- **Missing `TemplateParamsPlugin`** : template params like `%siteName` are now opt-in and will appear literally without the plugin +- **Missing `AliasSortingPlugin`** : `before:`/`after:` tag priorities are now opt-in and will be silently ignored without the plugin +- **Deprecated property names** : `children`, `hid`, `vmid`, `body: true` are no longer auto-converted +- **Removed `mode` option** : `{ mode: 'server' }` on `head.push()` is silently ignored All rules use ESLint-style config and can be individually disabled: @@ -35,31 +41,16 @@ Remove `ValidatePlugin` once your migration is complete, or keep it for ongoing --- -## Table of Contents - -1. [Legacy Property Names](#1-legacy-property-names) - High Impact -2. [Schema.org Plugin](#2-schemaorg-plugin) - High Impact -3. [Server Composables](#3-server-composables) - Medium-High Impact -4. [Vue Legacy Exports](#4-vue-legacy-exports) - Medium Impact -5. [Core API Changes](#5-core-api-changes) - Medium Impact -6. [Schema.org Config Options](#6-schemaorg-config-options) - Low-Medium Impact -7. [Server Utilities](#7-server-utilities) - Low Impact -8. [Type Changes](#8-type-changes) - Low Impact -9. [Hooks](#9-hooks) - Low Impact -10. [Other Removed APIs](#10-other-removed-apis) - Low Impact - ---- - -## 1. Legacy Property Names +## Legacy Property Names -**Impact: High** - Many existing codebases use these legacy property names. +🚦 Impact Level: **High** The `DeprecationsPlugin` that automatically converted legacy property names has been removed. You must update your head entries to use the current property names. ### `children` → `innerHTML` ```diff -head.push({ +useHead({ script: [{ - children: 'console.log("hello")', + innerHTML: 'console.log("hello")', @@ -70,7 +61,7 @@ head.push({ ### `hid` / `vmid` → `key` ```diff -head.push({ +useHead({ meta: [{ - hid: 'description', + key: 'description', @@ -81,7 +72,7 @@ head.push({ ``` ```diff -head.push({ +useHead({ meta: [{ - vmid: 'og:title', + key: 'og:title', @@ -94,7 +85,7 @@ head.push({ ### `body: true` → `tagPosition: 'bodyClose'` ```diff -head.push({ +useHead({ script: [{ src: '/script.js', - body: true, @@ -114,9 +105,9 @@ head.push({ --- -## 2. Schema.org Plugin +## Schema.org Plugin -**Impact: High** - Anyone using `@unhead/schema-org` will need to update. +🚦 Impact Level: **High** The `PluginSchemaOrg` and `SchemaOrgUnheadPlugin` exports have been removed. Use `UnheadSchemaOrg` instead. @@ -132,18 +123,6 @@ const head = createHead({ }) ``` -```diff -- import { SchemaOrgUnheadPlugin } from '@unhead/schema-org' -+ import { UnheadSchemaOrg } from '@unhead/schema-org' - -const head = createHead({ - plugins: [ -- SchemaOrgUnheadPlugin() -+ UnheadSchemaOrg() - ] -}) -``` - For Vue users: ```diff @@ -151,77 +130,59 @@ For Vue users: + import { UnheadSchemaOrg } from '@unhead/schema-org/vue' ``` ---- - -## 3. Server Composables +### Schema.org Config Options -**Impact: Medium-High** - Common in SSR applications. +The following config options have been removed: -The `useServerHead`, `useServerHeadSafe`, and `useServerSeoMeta` composables have been removed. Use the standard composables instead. - -### `useServerHead` → `useHead` +| Removed Option | Replacement | +|---------------|-------------| +| `canonicalHost` | `host` | +| `canonicalUrl` | `path` + `host` | +| `position` | Use `tagPosition` on individual schema entries | +| `defaultLanguage` | Use `inLanguage` on schema nodes | +| `defaultCurrency` | Use `priceCurrency` on schema nodes | ```diff -- import { useServerHead } from 'unhead' -+ import { useHead } from 'unhead' - -- useServerHead({ title: 'My Page' }) -+ useHead({ title: 'My Page' }) +UnheadSchemaOrg({ +- canonicalHost: 'https://example.com', +- canonicalUrl: 'https://example.com/page', ++ host: 'https://example.com', ++ path: '/page', +}) ``` -### `useServerHeadSafe` → `useHeadSafe` +--- -```diff -- import { useServerHeadSafe } from 'unhead' -+ import { useHeadSafe } from 'unhead' +## Server Composables Removed -- useServerHeadSafe({ title: userInput }) -+ useHeadSafe({ title: userInput }) -``` +🚦 Impact Level: **Medium-High** -### `useServerSeoMeta` → `useSeoMeta` +The `useServerHead`, `useServerHeadSafe`, and `useServerSeoMeta` composables have been removed. Use the standard composables instead. ```diff -- import { useServerSeoMeta } from 'unhead' -+ import { useSeoMeta } from 'unhead' +- import { useServerHead, useServerSeoMeta } from 'unhead' ++ import { useHead, useSeoMeta } from 'unhead' + +- useServerHead({ title: 'My Page' }) ++ useHead({ title: 'My Page' }) - useServerSeoMeta({ description: 'My description' }) + useSeoMeta({ description: 'My description' }) ``` -**Note:** If you need server-only head management, use conditional logic or framework-specific SSR detection instead of mode-based composables. - ---- - -## 4. Vue Legacy Exports - -**Impact: Medium** - Affects Vue users on older setups. - -### `/legacy` Export Path Deprecated - -The `@unhead/vue/legacy` import still works but emits a runtime deprecation warning. Update to the explicit client or server import: - -```diff -- import { createHead } from '@unhead/vue/legacy' -+ import { createHead } from '@unhead/vue/client' -// or for SSR -+ import { createHead } from '@unhead/vue/server' -``` - -### `createHeadCore` Removed +If you need server-only head management, use conditional logic: -```diff -- import { createHeadCore } from '@unhead/vue' -+ import { createHead } from '@unhead/vue/server' -// or for client -+ import { createHead } from '@unhead/vue/client' +```ts +if (import.meta.server) { + useHead({ title: 'Server Only' }) +} ``` --- -## 5. Core API Changes +## Core API Changes -**Impact: Medium** - Affects custom integrations and advanced usage. +🚦 Impact Level: **Medium** ### `createHeadCore` → `createUnhead` @@ -235,8 +196,6 @@ The `@unhead/vue/legacy` import still works but emits a runtime deprecation warn ### `headEntries()` → `entries` Map -The `headEntries()` method has been removed. Access entries directly via the `entries` Map. - ```diff - const entries = head.headEntries() + const entries = [...head.entries.values()] @@ -253,7 +212,7 @@ head.push({ + }) ``` -If you need server-only or client-only head management, use the appropriate `createHead` function: +Use the appropriate `createHead` function instead: ```ts // Client-side @@ -265,34 +224,35 @@ import { createHead } from 'unhead/server' --- -## 6. Schema.org Config Options +## Vue Legacy Exports -**Impact: Low-Medium** - Affects users with custom Schema.org configuration. +🚦 Impact Level: **Medium** -The following Schema.org config options have been removed: +### `/legacy` Export Path Deprecated -| Removed Option | Replacement | -|---------------|-------------| -| `canonicalHost` | `host` | -| `canonicalUrl` | `path` + `host` | -| `position` | Use `tagPosition` on individual schema entries | -| `defaultLanguage` | Use `inLanguage` on schema nodes | -| `defaultCurrency` | Use `priceCurrency` on schema nodes | +The `@unhead/vue/legacy` import still works but emits a runtime deprecation warning. Update to the explicit client or server import: ```diff -UnheadSchemaOrg({ -- canonicalHost: 'https://example.com', -- canonicalUrl: 'https://example.com/page', -+ host: 'https://example.com', -+ path: '/page', -}) +- import { createHead } from '@unhead/vue/legacy' ++ import { createHead } from '@unhead/vue/client' +// or for SSR ++ import { createHead } from '@unhead/vue/server' +``` + +### `createHeadCore` Removed + +```diff +- import { createHeadCore } from '@unhead/vue' ++ import { createHead } from '@unhead/vue/server' +// or for client ++ import { createHead } from '@unhead/vue/client' ``` --- -## 7. Server Utilities +## Server Utilities -**Impact: Low** - Only affects users parsing HTML for head extraction. +🚦 Impact Level: **Low** ### `extractUnheadInputFromHtml` → `parseHtmlForUnheadExtraction` @@ -308,125 +268,58 @@ The function has been moved from `unhead/server` to `unhead/parser`. --- -## 8. Type Changes +## Hooks -**Impact: Low** - Only affects TypeScript users with explicit type imports. +🚦 Impact Level: **Low** -### Removed Type Aliases +The following hooks have been removed: -| Removed Type | Replacement | -|-------------|-------------| -| `Head` | `HeadTag` or specific tag types | -| `ResolvedHead` | `ResolvedHeadTag` | -| `MergeHead` | Use generics directly | -| `MetaFlatInput` | `MetaFlat` | -| `ResolvedMetaFlat` | `MetaFlat` | -| `RuntimeMode` | Removed (no replacement needed) | +- `init` : No longer needed +- `dom:renderTag` : DOM rendering is now synchronous +- `dom:rendered` : Use the `onRendered` option on `useHead()` instead -```diff -- import type { Head, MetaFlatInput, RuntimeMode } from 'unhead' -+ import type { HeadTag, MetaFlat } from 'unhead' -``` - ---- - -## 9. Hooks - -**Impact: Low** - Only affects users with custom hook implementations. - -### `init` Hook Removed - -The `init` hook has been removed from `HeadHooks`. +The `dom:beforeRender` hook is now synchronous and `renderDOMHead` no longer returns a Promise: ```diff -- head.hooks.hook('init', (ctx) => { -- // Initialize something -- }) -``` - -### `dom:renderTag` Hook Removed - -The `dom:renderTag` hook has been removed. This hook was called for each tag during DOM rendering. - -```diff -- head.hooks.hook('dom:renderTag', (ctx, document, track) => { -- // Custom tag rendering logic -- }) -``` - -### `dom:rendered` Hook Removed - -The `dom:rendered` hook has been removed. DOM rendering is now synchronous. - -```diff -- head.hooks.hook('dom:rendered', ({ renders }) => { -- // Post-render logic -- }) -``` - -If you need to run logic after DOM rendering, call `renderDOMHead` directly and add your logic after: - -```ts -import { renderDOMHead } from 'unhead/client' - -renderDOMHead(head) -// Your post-render logic here -``` - -### `dom:beforeRender` is Now Synchronous - -The `dom:beforeRender` hook no longer supports async handlers. - -```diff -head.hooks.hook('dom:beforeRender', (ctx) => { -- await someAsyncOperation() - ctx.shouldRender = true -}) +- await renderDOMHead(head, { document }) ++ renderDOMHead(head, { document }) ``` -### `renderDOMHead` is Now Synchronous - -The `renderDOMHead` function no longer returns a Promise. +The SSR hooks (`ssr:beforeRender`, `ssr:render`, `ssr:rendered`) are now synchronous and `renderSSRHead` no longer returns a Promise: ```diff -- await renderDOMHead(head, { document }) -+ renderDOMHead(head, { document }) +- const head = await renderSSRHead(head) ++ const head = renderSSRHead(head) ``` --- -## 10. Other Removed APIs - -**Impact: Low** - Internal utilities rarely used directly. - -### `resolveScriptKey` - -The `resolveScriptKey` function is no longer exported. This was an internal utility. - -### `resolveUnrefHeadInput` (Vue) - -```diff -- import { resolveUnrefHeadInput } from '@unhead/vue' -``` +## Type Changes -Reactive head input resolution now happens automatically within the head manager. +🚦 Impact Level: **Low** -### `setHeadInjectionHandler` (Vue) +| Removed Type | Replacement | +|-------------|-------------| +| `Head` | `HeadTag` or specific tag types | +| `ResolvedHead` | `ResolvedHeadTag` | +| `MergeHead` | Use generics directly | +| `MetaFlatInput` | `MetaFlat` | +| `ResolvedMetaFlat` | `MetaFlat` | +| `RuntimeMode` | Removed (no replacement needed) | ```diff -- import { setHeadInjectionHandler } from '@unhead/vue' -- setHeadInjectionHandler(() => myHead) +- import type { Head, MetaFlatInput, RuntimeMode } from 'unhead' ++ import type { HeadTag, MetaFlat } from 'unhead' ``` -Head injection is now handled automatically. - -### `DeprecationsPlugin` +--- -```diff -- import { DeprecationsPlugin } from 'unhead/plugins' -``` +## Other Removed APIs -Instead of using this plugin, update your code to use the current property names (see [Section 1](#1-legacy-property-names)). +- `resolveScriptKey` : Internal utility, no longer exported +- `DeprecationsPlugin` : Update property names directly instead +- `resolveUnrefHeadInput` (Vue) : Reactive resolution now happens automatically +- `setHeadInjectionHandler` (Vue) : Head injection is handled automatically --- @@ -458,13 +351,3 @@ Instead of using this plugin, update your code to use the current property names + import { createHead } from '@unhead/vue/client' + import { createHead } from '@unhead/vue/server' ``` - ---- - -## Need Help? - -If you encounter issues during migration: - -1. Check the [documentation](https://unhead.unjs.io) -2. Search [existing issues](https://github.com/unjs/unhead/issues) -3. Open a [new issue](https://github.com/unjs/unhead/issues/new) if needed diff --git a/docs/content/6.migration-guide/2.v2.md b/docs/content/6.migration-guide/2.v2.md new file mode 100644 index 000000000..07d5eb3ad --- /dev/null +++ b/docs/content/6.migration-guide/2.v2.md @@ -0,0 +1,202 @@ +--- +title: "Migrate to v2" +description: "Migrate from Unhead v1 to v2. Covers subpath exports, removed implicit context, opt-in plugins, and more." +navigation: + title: "v2" +--- + +With the release of Unhead v2, we now have first-class support for other frameworks. This guide covers the v1 to v2 migration. + +## Client / Server Subpath Exports + +🚦 Impact Level: **Critical** + +**⚠️ Breaking Changes:** + +- `createServerHead()`{lang="ts"} and `createHead()`{lang="ts"} exports from `unhead` are removed + +The path where you import `createHead` from has been updated to be a subpath export. + +**Client bundle:** + +```diff +-import { createServerHead } from 'unhead' ++import { createHead } from 'unhead/client' + +// avoids bundling server plugins +createHead() +``` + +**Server bundle:** + +```diff +-import { createServerHead } from 'unhead' ++import { createHead } from 'unhead/server' + +// avoids bundling server plugins +-createServerHead() ++createHead() +``` + +--- + +## Removed Implicit Context + +🚦 Impact Level: **Critical** + +**⚠️ Breaking Changes:** + +- `getActiveHead()`{lang="ts"}, `activeHead`{lang="ts"} exports are removed + +The implicit context implementation kept a global instance of Unhead available so that you could use the `useHead()`{lang="ts"} composables anywhere in your application. + +In v2, the core composables no longer have access to the Unhead instance. Instead, you must pass the Unhead instance to the composables. + +::note +Passing the instance is only relevant if you're importing from `unhead`. In JavaScript frameworks we tie the context to the framework itself so you don't need to worry about this. +:: + +```ts [TypeScript v2] +import { useHead } from 'unhead' + +// example of getting the instance +const unheadInstance = useMyApp().unhead +useHead(unheadInstance, { + title: 'Looks good' +}) +``` + +--- + +## Removed `vmid`, `hid`, `children`, `body` + +🚦 Impact Level: **High** + +For legacy support with Vue Meta we allowed end users to provide deprecated properties: `vmid`, `hid`, `children` and `body`. + +You must update these properties to the appropriate replacement or remove them. See the [v3 migration guide](/docs/content/migration-guide/v3#legacy-property-names) for the replacements. + +--- + +## Opt-in Template Params & Tag Alias Sorting + +🚦 Impact Level: **High** + +To reduce the bundle size and improve performance, we've moved the template params and tag alias sorting to optional plugins. + +```ts +import { AliasSortingPlugin, TemplateParamsPlugin } from 'unhead/plugins' + +createHead({ + plugins: [TemplateParamsPlugin, AliasSortingPlugin] +}) +``` + +--- + +## Promise Input Support + +🚦 Impact Level: **Medium** + +If you have promises as input they will no longer be resolved, either await the promise before passing it along or register the optional promises plugin. + +```ts +import { PromisePlugin } from 'unhead/plugins' + +const unhead = createHead({ + plugins: [PromisePlugin] +}) +``` + +--- + +## Updated `useScript()`{lang="ts"} + +🚦 Impact Level: **High** + +**⚠️ Breaking Changes:** + +- Script instance is no longer augmented as a proxy and promise +- `script.proxy`{lang="ts"} is rewritten for simpler, more stable behavior +- `stub()`{lang="ts"} and runtime hook `script:instance-fn` are removed + +**Replacing promise usage** + +```diff +const script = useScript() + +-script.then(() => console.log('loaded') ++script.onLoaded(() => console.log('loaded')) +``` + +**Replacing proxy usage** + +```diff +const script = useScript('..', { + use() { return { foo: [] } } +}) + +-script.foo.push('bar') ++script.proxy.foo.push('bar') +``` + +--- + +## Tag Sorting Updated + +🚦 Impact Level: **Low** + +[Capo.js](https://rviscomi.github.io/capo.js/) sorting is now the default. You can opt-out: + +```ts +createHead({ + disableCapoSorting: true, +}) +``` + +--- + +## Default SSR Tags + +🚦 Impact Level: **Low** + +When SSR Unhead will now insert important default tags for you: + +- `` +- `` +- `` + +```ts +import { createHead } from 'unhead/server' + +// disable when creating the head instance +const head = createHead({ + disableDefaults: true, +}) +``` + +--- + +## CJS Exports Removed + +🚦 Impact Level: **Low** + +CommonJS exports have been removed in favor of ESM only. + +```diff +-const { createHead } = require('unhead/client') ++import { createHead } from 'unhead/client' +``` + +--- + +## Deprecated `@unhead/schema` + +🚦 Impact Level: **Low** + +The `@unhead/schema` package is deprecated. Import from `unhead/types` or `unhead` instead. + +```diff +-import { HeadTag } from '@unhead/schema' ++import { HeadTag } from 'unhead/types' +``` diff --git a/docs/head/1.guides/releases/v3.md b/docs/content/7.releases/1.v3.md similarity index 84% rename from docs/head/1.guides/releases/v3.md rename to docs/content/7.releases/1.v3.md index 86b3a1b5c..9edd6f8b3 100644 --- a/docs/head/1.guides/releases/v3.md +++ b/docs/content/7.releases/1.v3.md @@ -1,15 +1,15 @@ --- title: "Unhead v3" -description: "Unhead v3 release notes - streaming SSR, synchronous rendering, and significant performance improvements." +description: "Unhead v3 release notes: streaming SSR, synchronous rendering, and significant performance improvements." navigation: title: "v3" --- Unhead v3 rebuilds the rendering engine from the ground up. The motivation: **streaming SSR**. Frameworks like Nuxt, SolidStart, and SvelteKit stream HTML to the browser as data loads, but head tags were still stuck in a request/response model, resolved once and never updated. To fix this properly, we had to make rendering synchronous, pluggable, and side-effect free. The result is a faster, smaller, and more capable head manager. -## 📣 Highlights +## Highlights -### 🌊 Streaming SSR +### Streaming SSR Head tags now update dynamically as suspense boundaries resolve during streaming. As each chunk streams to the browser, new `Codestin Search App + + + +``` + +Seen as: ``{lang="html"}, ``{lang="html"}, ``{lang="html"} or more granular tags such as ``{lang="html"}, `<Meta>`{lang="html"}, `<Link>`{lang="html"}, etc. + +### Ecosystem + +Traditionally frameworks like React, [Vue](https://vuejs.org) and Angular have left it up to their respective ecosystems to solve this problem. Vue had [Vue Meta](https://vue-meta.nuxtjs.org/) and [VueUse Head](https://github.com/vueuse/head) and +React had and continues to have [React Helmet](https://github.com/nfl/react-helmet). + +We see some frameworks shifting towards providing simple support as part of their core, such as in [React v19](https://react.dev/blog/2024/12/05/react-19) +and in [Angular v14](https://angular.io/guide/releases#angular-v14-0-0). + +These solutions tend to be reliant on the component pattern, which is limited in its capabilities. + +## A quick recap on Unhead + +Unhead started out humbly 5 years ago as [`@vueuse/head`](https://github.com/vueuse/head). Since then it's joined +[UnJS](https://unjs.io/) as a high-quality, single-purpose package that works anywhere and is downloaded over ~100k times a day. + +:UnheadDownloads{class="my-10 lg:-mx-20 lg:w-[125%] rounded overflow-hidden"} + +Built from bullet-proof primitives such as `useHead()`{lang="ts"} and `useSeoMeta()`{lang="ts"}, Unhead is building out the ecosystem +of head management which is being realized through the v2 release. + +## v2 Release + +For the full changelog of changes please reference the [v2 roadmap](https://github.com/unjs/unhead/issues/395) GitHub Issue. + +### New Framework Supported + +Unhead started as a Vue-focused solution, but with v2, we've expanded to support all major frontend frameworks with +deep reactivity integrations. + +:FrameworkSelectorMinimal{ignore-redirect} + +```ts +import { useHead } from '@unhead/dynamic-import' + +useHead({ + title: '👋 @FRAMEWORK_NAME@' +}) +``` + +- **Vue** : `@unhead/vue`: [Installation](/docs/vue/head/guides/get-started/installation) & [Reactivity Guide](/docs/vue/head/guides/core-concepts/reactivity-and-context) +- **React** : `@unhead/react`: [Installation](/docs/react/head/guides/get-started/installation) & [Reactivity Guide](/docs/react/head/guides/core-concepts/reactivity) +- **Svelte** : `@unhead/svelte`: [Installation](/docs/svelte/head/guides/get-started/installation) & [Reactivity Guide](/docs/svelte/head/guides/core-concepts/reactivity) +- **Solid.js** : `@unhead/solid-js`: [Installation](/docs/solid-js/head/guides/get-started/installation) & [Reactivity Guide](/docs/solid-js/head/guides/core-concepts/reactivity) +- **Angular** : `@unhead/angular`: [Installation](/docs/angular/head/guides/get-started/installation) & [Reactivity Guide](/docs/angular/head/guides/core-concepts/reactivity) + +Each framework integration provides the same powerful features with idiomatic patterns for that ecosystem. + +### ⚡ Runtime Performance Improvements + +Every tenth of a millisecond counts performance. In this release, much of the core has been rewritten to improve performance and tree-shakability. + +**Rewritten Core**: With the [core being rewritten](https://github.com/unjs/unhead/pull/488), optimizations were done +to improve the fast-path times. + +- ✅ SSR **+51% Faster** 0.34ms ⇢ 0.20ms ([Benchmark: Medium Site](https://github.com/unjs/unhead/blob/main/bench/ssr-harlanzw-com-e2e.bench.ts)) + +**Faster INP**: Tag resolves are now [cached between DOM renders](https://github.com/unjs/unhead/pull/504). For large sites, this _can_ lead to lower INP when switching between pages. + +- ✅ CSR Page Switching **+64% Faster** 0.43ms ⇢ 0.22ms ([Benchmark: 10 Page Changes x useHead()](https://github.com/unjs/unhead/blob/main/packages/unhead/test/bench/ssr-perf.bench.ts)) + +**Leaner Core**: Through aggressive code optimizations and moving some features to opt-in, we see an improvement in the bundle size. + +- ✅ Client: **21% smaller** - 13.6 kB (gz 5.3 kB) ⇢ 10.7 kB (gz 4.5 kB) ([Minimal: useHead() + createUnhead()](https://github.com/nuxt/nuxt/pull/31169/files#diff-04a7585c5d6ddd5cf80321c69bc6e07cd85e9e86ae35fa5f9c036651f137c312R26) ) +- ✅ Server: **22% smaller** - 10.3 kB (gz 4.1 kB) ⇢ 8 kB (gz 3.4 kB) ([Minimal: useHead() + createUnhead()](https://github.com/nuxt/nuxt/pull/31169/files#diff-04a7585c5d6ddd5cf80321c69bc6e07cd85e9e86ae35fa5f9c036651f137c312R26)) + +Benchmarks do not reflect real-world performance. + +:UnheadTwoGraphs + +### Capo.js Sorting + +Unhead v2 now applies capo.js sorting to all tags by default which provides optimizations to improve the +performance of your site for end users. This sorting follows best practices for resource loading order in the document head. + +:CapoExample + +### 📦 Bundle Improvements + +**Single dependency** Unhead now relies on a single dependency, [hookable](https://github.com/unjs/hookable), used for pluggability. + +- ✅ **5 fewer** dependencies + +**ESM & dropped workspace packages** Only ESM is now published, workspace packages are deprecated for subpath exports. + +- ✅ **14kb reduction** in `node_modules` + +## 🔄 Upgrading to v2 + +The migration path is straightforward; Unhead provides a `legacy` subpath build to ease the transition. + +See the [Migrate to v2](/docs/content/migration-guide/v2) guide. + +## 🔮 The Future + +Now that Unhead has a battle-tested core and supports all major frameworks, we can start to build out the ecosystem of head management. Here's what's on our roadmap: + +### Short Term: Improved Tag Validation + +For several tags, the spec requires certain attributes when you use another attribute. For example, the `<link>`{lang="html"} tag +only requires an `as` attribute when you use `rel="preload"`{lang="html"}. + +The type system does not enforce this to avoid potential type juggling; however, it can lead to mistakes. + +To catch broken tags we have several options: +- Improve the type system for optionally required attributes +- Implement [ESLint](https://eslint.org) rules to catch anything the types haven't +- Use a runtime plugin in development to validate resolved tags + +### Medium Term: Third-Party Scripts + +While Unhead already has a lower-level way to work with third-party scripts `useScript()`{lang="ts"}, we can make them easier and more secure +to use by introducing higher-level composables, such as `useGoogleAnalytics()`{lang="ts"}, `useFacebookPixel()`{lang="ts"}, etc. + +These would hook into framework lifecycles to ensure optimal performance and security while providing improved developer experience. + +```ts +const { proxy } = useGoogleAnalytics({ + id: 'UA-123456789', +}) + +proxy.gtag('page_view', { + page_path: '/my-page', +}) +``` + +### Exploratory: OG Image + +Turn HTML templates into OG images with a simple composable. + +```ts +useOgImage('<div class="bg-red-500">my image :)</div>') +``` + +## 🙏 Thank You + +This release wouldn't be possible without our amazing community. Special thanks to all contributors who helped with code, testing, and documentation.