Skip to content

fix(legacy): restore exports for v2 migration#741

Merged
harlan-zw merged 7 commits into
mainfrom
fix/legacy-exports
Apr 12, 2026
Merged

fix(legacy): restore exports for v2 migration#741
harlan-zw merged 7 commits into
mainfrom
fix/legacy-exports

Conversation

@harlan-zw

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

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

N/A

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

The legacy entry points (unhead/legacy and @unhead/vue/legacy) had regressed to emitting a deprecation warning only, leaving v2 consumers without the createHead / createServerHead APIs they rely on during migration. This restores the legacy factories (plus getActiveHead and createHeadCore on the core entry) with DeprecationsPlugin and the other v2 plugins pre-registered so v1/v2 tag props (children, hid, vmid, body) keep working. Also adds ./legacy to the unhead package exports and build.config.ts so the subpath is actually published.

Summary by CodeRabbit

  • New Features

    • Added a legacy entrypoint to provide v1→v3 migration support and published a package subpath for it.
    • New legacy-friendly client/server factories that auto-apply migration plugins and expose the latest head instance.
    • Replaced a runtime deprecation warning with a legacy compatibility implementation in the Vue package.
  • Tests

    • Added unit tests covering legacy prop mappings, body handling, and deduplication behavior.

…tion

The legacy entry points (`unhead/legacy` and `@unhead/vue/legacy`) regressed
to a deprecation warning, breaking v2 consumers mid-migration. Re-export
`createHead`, `createServerHead`, `getActiveHead`, and `createHeadCore` with
the `DeprecationsPlugin` (plus the other legacy plugins for the core entry)
pre-registered so v2 tag props keep working.
@coderabbitai

coderabbitai Bot commented Apr 12, 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 legacy entrypoint and exports for Unhead and Vue: build config and package exports for legacy, a new src/legacy/index.ts implementing DeprecationsPlugin, legacy plugin list and active instance state, and Vue helpers that prepend legacy plugins to client/server head creation. (39 words)

Changes

Cohort / File(s) Summary
Build & Package Config
packages/unhead/build.config.ts, packages/unhead/package.json
Added src/legacy/index to unbuild entries (output name legacy) and added ./legacy subpath export plus typesVersions mapping to dist/legacy.*.
Unhead Legacy Module
packages/unhead/src/legacy/index.ts
New legacy entry: DeprecationsPlugin that normalizes legacy tag props (props.childrentag.innerHTML, props.hid/props.vmidtag.key, props.bodytag.tagPosition, props.renderPrioritytag.tagPriority); exports legacyPlugins; activeHead state and getActiveHead(); createHead / createServerHead wrappers merging legacyPlugins; createHeadCore = createUnhead alias.
Vue Legacy Integration
packages/vue/src/legacy.ts
Replaced runtime deprecation warning with legacy compatibility helpers: exported legacyPlugins, createHead, createServerHead (both prepend legacyPlugins), and createClientHead alias; preserves other client exports.
Tests
packages/unhead/test/unit/plugins/deprecations.test.ts
Added tests validating DeprecationsPlugin maps legacy props to v3 fields, strips legacy props, handles body truthy/falsy cases, and deduplicates hid across pushes.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant LegacyAPI as Legacy Entrypoint
    participant UnheadFactory as Unhead Factory
    participant Hooks as Entry Hooks
    participant Deprecator as DeprecationsPlugin

    App->>LegacyAPI: createHead(options)
    LegacyAPI->>UnheadFactory: create head with merged legacyPlugins
    UnheadFactory->>UnheadFactory: initialize instance (assign activeHead)
    App->>UnheadFactory: push entries/tags
    UnheadFactory->>Hooks: emit entries:normalize
    Hooks->>Deprecator: invoke for each tag
    Deprecator->>Deprecator: map children→innerHTML, hid/vmid→key, body→tagPosition, renderPriority→tagPriority
    Deprecator->>UnheadFactory: return normalized entries
    UnheadFactory->>App: render/manage normalized head entries
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hop through code to mend the past,

children settle as innerHTML at last,
hid and vmid now meet as one key,
body finds its proper place to be,
a tidy bridge from old to new — hop, wee!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.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 accurately summarizes the main change: restoring legacy exports for v2 migration, which aligns with the PR's core objective of reintroducing the legacy factories and APIs.
Description check ✅ Passed The description covers all required template sections: linked issue (N/A), type of change (bug fix selected), and detailed explanation of what was fixed and why, with specific APIs and props mentioned.

✏️ 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 fix/legacy-exports

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

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.7 kB 4.4 kB
Server (Minimal) 10.6 kB 4.3 kB
Vue Client (Minimal) 11.8 kB 4.8 kB
Vue Server (Minimal) 11.6 kB 4.7 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/unhead/src/legacy/index.ts`:
- Line 57: The function createServerHead currently uses
Omit<CreateServerHeadOptions, 'propsResolver'> which is a typo and fails to
exclude the actual internal key; update the Omit to exclude 'propResolvers'
instead so propResolvers is not exposed to callers — locate the
createServerHead<T ...> declaration and replace the Omit key from
'propsResolver' to 'propResolvers' to enforce the intended API contract
referencing CreateServerHeadOptions and the propResolvers property.
- Around line 21-35: The current remapping uses truthy checks and thus skips
falsy-but-valid values (e.g., empty string) so legacy props like children, hid,
vmid, and body can leak; update the checks to key-existence checks (e.g., use
Object.prototype.hasOwnProperty.call(tag.props, 'children') or the 'in'
operator) for tag.props.children -> tag.innerHTML, tag.props.hid -> tag.key,
tag.props.vmid -> tag.key, and tag.props.body -> tag.tagPosition, and still
delete the original prop after remapping (keep using tag.innerHTML, tag.key,
tag.tagPosition as the target symbols).

In `@packages/vue/src/legacy.ts`:
- Around line 15-19: The legacy Vue wrapper createHead currently only prepends
DeprecationsPlugin when calling _createClientHead, but must include the full v2
compatibility plugin set used by the core legacy entry; update the plugins value
passed to _createClientHead so it prepends the same compatibility plugins array
as the core legacy entry (not just DeprecationsPlugin), e.g. replace the single
DeprecationsPlugin prepended in createHead with the full v2-compat plugins list
used in the core legacy implementation, and do the same change for the other
legacy wrapper function on lines 26-30 so both createHead wrappers mirror the
core legacy plugin set.
🪄 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: 4d224cfe-9cbe-4828-aa83-26a2c1ec74c4

📥 Commits

Reviewing files that changed from the base of the PR and between e155fbe and cd90640.

📒 Files selected for processing (4)
  • packages/unhead/build.config.ts
  • packages/unhead/package.json
  • packages/unhead/src/legacy/index.ts
  • packages/vue/src/legacy.ts

Comment thread packages/unhead/src/legacy/index.ts
Comment thread packages/unhead/src/legacy/index.ts Outdated
Comment thread packages/vue/src/legacy.ts
@harlan-zw harlan-zw changed the title fix(legacy): restore createHead/createServerHead exports for v2 migration fix(legacy): restore exports for v2 migration Apr 12, 2026
- @unhead/vue/legacy createHead/createServerHead now prepend the full v2 migration plugin set (DeprecationsPlugin, PromisesPlugin, TemplateParamsPlugin, AliasSortingPlugin) to mirror unhead/legacy
- Fix Omit<CreateServerHeadOptions, 'propsResolver'> typo → 'propResolvers'

@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/unhead/src/legacy/index.ts (1)

21-35: ⚠️ Potential issue | 🟡 Minor

Use key-existence checks when remapping legacy props.

Line 21 / Line 25 / Line 29 / Line 33 currently skip falsy-but-valid values, so children: '', hid: '', vmid: '', and body: false can leak through without normalization or cleanup.

🔧 Proposed fix
-        if (tag.props.children) {
+        if ('children' in tag.props) {
           tag.innerHTML = tag.props.children
           delete tag.props.children
         }
-        if (tag.props.hid) {
+        if ('hid' in tag.props) {
           tag.key = tag.props.hid
           delete tag.props.hid
         }
-        if (tag.props.vmid) {
+        if ('vmid' in tag.props) {
           tag.key = tag.props.vmid
           delete tag.props.vmid
         }
-        if (tag.props.body) {
-          tag.tagPosition = 'bodyClose'
-          delete tag.props.body
-        }
+        if ('body' in tag.props) {
+          if (tag.props.body)
+            tag.tagPosition = 'bodyClose'
+          delete tag.props.body
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/legacy/index.ts` around lines 21 - 35, The current
remapping logic uses truthy checks and therefore skips valid falsy values;
change each conditional to check property existence (e.g.,
Object.prototype.hasOwnProperty.call(tag.props, 'children') or 'children' in
tag.props) before remapping so empty strings and boolean false are handled and
then perform the same assignment and delete (apply this for children ->
innerHTML, hid -> key, vmid -> key, and body -> tagPosition).
🤖 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/vue/src/legacy.ts`:
- Line 15: The exported legacyPlugins constant currently lacks a public type
annotation causing declaration emit errors (TS2883); add an explicit type
annotation so its exported type is stable: annotate legacyPlugins as
NonNullable<CreateClientHeadOptions['plugins']> (i.e. HeadPluginInput[]) and
ensure the export remains export const legacyPlugins:
NonNullable<CreateClientHeadOptions['plugins']> = [DeprecationsPlugin,
PromisesPlugin, TemplateParamsPlugin, AliasSortingPlugin]; this will convert the
inferred readonly tuple into the intended public plugin array type.

---

Duplicate comments:
In `@packages/unhead/src/legacy/index.ts`:
- Around line 21-35: The current remapping logic uses truthy checks and
therefore skips valid falsy values; change each conditional to check property
existence (e.g., Object.prototype.hasOwnProperty.call(tag.props, 'children') or
'children' in tag.props) before remapping so empty strings and boolean false are
handled and then perform the same assignment and delete (apply this for children
-> innerHTML, hid -> key, vmid -> key, and body -> tagPosition).
🪄 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: 13ad9409-f669-409b-b89d-d519e87ed6a2

📥 Commits

Reviewing files that changed from the base of the PR and between 671302c and 5a14452.

📒 Files selected for processing (2)
  • packages/unhead/src/legacy/index.ts
  • packages/vue/src/legacy.ts

Comment thread packages/vue/src/legacy.ts Outdated
- Add renderPriority → tagPriority mapping for v2 compat
- Use 'body' in tag.props check so props.body=false still strips the prop
Verifies children/hid/vmid/body/renderPriority remap, body=false strips
without moving tag, and hid dedupe across separate entries.

@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/unhead/src/legacy/index.ts (1)

21-31: ⚠️ Potential issue | 🟡 Minor

Use existence checks for the remaining legacy prop remaps.

Lines 21, 25, and 29 still use truthy checks, so valid falsy values like children: '', hid: '', or vmid: '' are left on tag.props instead of being normalized. Match the body branch and switch these to key-existence checks before deleting the legacy prop.

🔧 Proposed fix
-        if (tag.props.children) {
+        if ('children' in tag.props) {
           tag.innerHTML = tag.props.children
           delete tag.props.children
         }
-        if (tag.props.hid) {
+        if ('hid' in tag.props) {
           tag.key = tag.props.hid
           delete tag.props.hid
         }
-        if (tag.props.vmid) {
+        if ('vmid' in tag.props) {
           tag.key = tag.props.vmid
           delete tag.props.vmid
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/legacy/index.ts` around lines 21 - 31, Change the truthy
checks for legacy prop remaps to existence checks so falsy-but-valid values are
normalized: for tag.props.children, tag.props.hid and tag.props.vmid use a
key-existence test (e.g. Object.prototype.hasOwnProperty.call or the 'in'
operator as used in the body branch) before assigning tag.innerHTML or tag.key
and deleting the legacy prop; update the branches that handle children, hid and
vmid to follow the same existence-check pattern as the body branch.
🤖 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/unhead/src/legacy/index.ts`:
- Around line 54-72: The module-level activeHead singleton is being overwritten
by createServerHead (assigning activeHead.value = _createServerHead(...)),
causing cross-request leakage; change createServerHead so it does NOT write to
the module-level activeHead and instead returns a fresh server head instance
(e.g., return _createServerHead<T>({...}) directly), and scope server heads to
the request (pass/store them in per-request context or use AsyncLocalStorage)
while keeping the module-level activeHead only for client-side
createHead/getActiveHead usage; update getActiveHead usage/docs to reflect it
only reads the global client head and ensure no server code relies on the module
singleton.

---

Duplicate comments:
In `@packages/unhead/src/legacy/index.ts`:
- Around line 21-31: Change the truthy checks for legacy prop remaps to
existence checks so falsy-but-valid values are normalized: for
tag.props.children, tag.props.hid and tag.props.vmid use a key-existence test
(e.g. Object.prototype.hasOwnProperty.call or the 'in' operator as used in the
body branch) before assigning tag.innerHTML or tag.key and deleting the legacy
prop; update the branches that handle children, hid and vmid to follow the same
existence-check pattern as the body branch.
🪄 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: 720fa27d-b110-43a0-97c8-4a8edb74dfdf

📥 Commits

Reviewing files that changed from the base of the PR and between 5a14452 and 4d1b166.

📒 Files selected for processing (1)
  • packages/unhead/src/legacy/index.ts

Comment on lines +54 to +72
export const activeHead: { value: Unhead<any> | null } = { value: null }

export function getActiveHead<T extends Record<string, any> = ResolvableHead>(): Unhead<T> | null {
return activeHead.value
}

export function createHead<T extends Record<string, any> = ResolvableHead>(options: CreateClientHeadOptions = {}): Unhead<T> {
return activeHead.value = _createClientHead<T>({
...options,
plugins: [...legacyPlugins, ...(options.plugins || [])],
})
}

export function createServerHead<T extends Record<string, any> = ResolvableHead>(options: Omit<CreateServerHeadOptions, 'propResolvers'> = {}): Unhead<T> {
return activeHead.value = _createServerHead<T>({
...options,
plugins: [...legacyPlugins, ...(options.plugins || [])],
})
}

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

Don't store the server head in a module-level singleton.

activeHead is process-global, and Line 68 assigns every SSR instance into it. Under concurrent requests, getActiveHead() can return another request's head and bleed tags/meta across responses. Keep the global only for the client path, and scope the server active head per request or async context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/legacy/index.ts` around lines 54 - 72, The module-level
activeHead singleton is being overwritten by createServerHead (assigning
activeHead.value = _createServerHead(...)), causing cross-request leakage;
change createServerHead so it does NOT write to the module-level activeHead and
instead returns a fresh server head instance (e.g., return
_createServerHead<T>({...}) directly), and scope server heads to the request
(pass/store them in per-request context or use AsyncLocalStorage) while keeping
the module-level activeHead only for client-side createHead/getActiveHead usage;
update getActiveHead usage/docs to reflect it only reads the global client head
and ensure no server code relies on the module singleton.

@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.

♻️ Duplicate comments (2)
packages/unhead/src/legacy/index.ts (2)

67-69: ⚠️ Potential issue | 🔴 Critical

Do not write SSR heads into module-global activeHead.

Line 68 stores each server-created head in process-global state, which can cause cross-request head leakage under concurrent SSR.

🛡️ Proposed fix
 export function createServerHead<T extends Record<string, any> = ResolvableHead>(options: Omit<CreateServerHeadOptions, 'propResolvers'> = {}): Unhead<T> {
-  return activeHead.value = _createServerHead<T>({
+  return _createServerHead<T>({
     ...options,
     plugins: [...legacyPlugins, ...(options.plugins || [])],
   })
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/legacy/index.ts` around lines 67 - 69, The function
createServerHead currently assigns the newly created server head into the
module-global activeHead (activeHead.value = _createServerHead...), which risks
cross-request leakage; change it to simply return the result of
_createServerHead<T>({...options}) without mutating activeHead.value so each
call produces an isolated Unhead instance; keep references to createServerHead,
activeHead and _createServerHead to locate the change and ensure no global state
is written here (if per-request activation is needed, perform activeHead updates
in request-scoped code instead).

21-31: ⚠️ Potential issue | 🟡 Minor

Use key-existence checks for all legacy prop remaps, not truthy checks.

Line 21, Line 25, and Line 29 still rely on truthiness, so falsy-but-present legacy values can bypass normalization and remain on tag.props.

🔧 Proposed fix
-        if (tag.props.children) {
+        if ('children' in tag.props) {
           tag.innerHTML = tag.props.children
           delete tag.props.children
         }
-        if (tag.props.hid) {
+        if ('hid' in tag.props) {
           tag.key = tag.props.hid
           delete tag.props.hid
         }
-        if (tag.props.vmid) {
+        if ('vmid' in tag.props) {
           tag.key = tag.props.vmid
           delete tag.props.vmid
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/legacy/index.ts` around lines 21 - 31, The code currently
uses truthy checks (if (tag.props.children), if (tag.props.hid), if
(tag.props.vmid)) so falsy-but-present legacy props (e.g. empty string, 0,
false) are ignored; change these to existence checks (e.g.
Object.prototype.hasOwnProperty.call(tag.props, 'children') or 'children' in
tag.props) and perform the same remap logic (assign tag.innerHTML / tag.key and
delete the original prop) regardless of the value's truthiness; apply the same
replacement for 'hid' and 'vmid' checks referencing tag.props.children,
tag.innerHTML, tag.props.hid, tag.key, tag.props.vmid to ensure normalization
always occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/unhead/src/legacy/index.ts`:
- Around line 67-69: The function createServerHead currently assigns the newly
created server head into the module-global activeHead (activeHead.value =
_createServerHead...), which risks cross-request leakage; change it to simply
return the result of _createServerHead<T>({...options}) without mutating
activeHead.value so each call produces an isolated Unhead instance; keep
references to createServerHead, activeHead and _createServerHead to locate the
change and ensure no global state is written here (if per-request activation is
needed, perform activeHead updates in request-scoped code instead).
- Around line 21-31: The code currently uses truthy checks (if
(tag.props.children), if (tag.props.hid), if (tag.props.vmid)) so
falsy-but-present legacy props (e.g. empty string, 0, false) are ignored; change
these to existence checks (e.g. Object.prototype.hasOwnProperty.call(tag.props,
'children') or 'children' in tag.props) and perform the same remap logic (assign
tag.innerHTML / tag.key and delete the original prop) regardless of the value's
truthiness; apply the same replacement for 'hid' and 'vmid' checks referencing
tag.props.children, tag.innerHTML, tag.props.hid, tag.key, tag.props.vmid to
ensure normalization always occurs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3d75478-a016-4105-a5ed-68028d32ece0

📥 Commits

Reviewing files that changed from the base of the PR and between 4d1b166 and 6bedfab.

📒 Files selected for processing (3)
  • packages/unhead/src/legacy/index.ts
  • packages/unhead/test/unit/plugins/deprecations.test.ts
  • packages/vue/src/legacy.ts

@harlan-zw harlan-zw merged commit c400d4f into main Apr 12, 2026
8 checks passed
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