Skip to content

fix(vue): keep reactive input at useHead boundary - #886

Open
harlan-zw wants to merge 6 commits into
mainfrom
fix/vue-reactive-head-push
Open

fix(vue): keep reactive input at useHead boundary#886
harlan-zw wants to merge 6 commits into
mainfrom
fix/vue-reactive-head-push

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

No linked issue.

❓ Type of change

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

📚 Description

Client head factories now accept core ResolvableHead input. Vue refs and computed values remain supported through useHead() and by server head factories.

This keeps Vue unwrapping inside the composable that owns dependency tracking and lifecycle cleanup. It also avoids adding a Vue resolver walk to every raw client entry.

⚠️ Breaking Changes

Raw client head.push() calls no longer accept Vue refs or computed values.

📝 Migration

 const title = ref('Reactive title')
-head.push({ title })
+useHead({ title }, { head })

Summary by CodeRabbit

  • Bug Fixes

    • Improved TypeScript compatibility for Vue head management APIs, including standard, legacy, server-rendered, and streaming usage.
    • Corrected server and streaming option typing to support resolver configuration accurately.
  • Documentation

    • Clarified which inputs support Vue reactivity and when to use useHead() for reactive tracking and cleanup.
  • Tests

    • Added coverage validating reactive inputs and type safety across client, server, and streaming scenarios.

VueHeadClient accepts reactive UseHeadInput values, but raw client entries neither resolved nor watched top-level refs and getters. Scope each watcher to its returned entry so patching replaces the source and disposal stops updates.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Vue head client and streaming APIs now use aligned ResolvableHead and UseHeadInput contracts, correct propResolvers option typing, and add TypeScript coverage for reactive inputs and stream boundaries.

Changes

Vue head input contracts

Layer / File(s) Summary
Client input contracts
packages/vue/src/types/schema.ts, packages/vue/src/client.ts, packages/vue/src/legacy.ts
Client head generics now use ResolvableHead, with documentation describing reactive input through useHead().
Streaming and server contracts
packages/vue/src/server.ts, packages/vue/src/stream/*
Server option types omit propResolvers correctly; streaming clients forward options directly and streaming server contexts use UseHeadInput.
Type validation and stream resolution
packages/vue/test/unit/types.test.ts, packages/vue/test/unit/e2e/*, packages/vue/vitest.config.ts, tsconfig.json
Type tests cover reactive and invalid head inputs, schemas use satisfies ReactiveHead, and stream module aliases are added.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • unjs/unhead#752: Updates the Vue streaming implementation touched by this PR.
  • unjs/unhead#870: Changes streaming resolver handling related to the updated propResolvers plumbing.
  • unjs/unhead#876: Addresses overlapping server adapter propResolvers option typing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: moving reactive input handling to the useHead boundary.
Description check ✅ Passed The description includes all required template sections and explains the change, breaking impact, and migration.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/vue-reactive-head-push
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/vue-reactive-head-push

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.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size

No notable changes

All bundles (14)
Bundle Gzipped Brotli Raw
Core
Client (Minimal) 5.4 kB 4.9 kB 13.6 kB
Client (Full) 9.5 kB 8.7 kB 24.9 kB
Client (Self-Contained) 5.7 kB 5.1 kB 14.3 kB
Server (Minimal) 4.9 kB 4.4 kB 12.3 kB
Server (Self-Contained) 5.2 kB 4.7 kB 13 kB
Vue
Vue Client (Minimal) 5.9 kB 5.3 kB 14.6 kB
Vue Client (Full) 10.5 kB 9.6 kB 27.7 kB
Vue Server (Minimal) 5.4 kB 4.9 kB 13.4 kB
React
React Client (Minimal) 5.8 kB 5.3 kB 14.7 kB
React Client (Full) 10.6 kB 9.6 kB 28.1 kB
React Server (Minimal) 5.2 kB 4.7 kB 13 kB
Schema.org
Schema.org (Minimal) 10.1 kB 9.2 kB 28 kB
Schema.org Imports 0.1 kB 0.1 kB 0.1 kB
Schema.org Vue Meta 0.5 kB 0.4 kB 1 kB

📦 Runtime Dependencies

No runtime dependency changes

All packages (10)
Package External deps Install size Largest dependency Skipped optional
@unhead/angular 10 773.6 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/bundler 17 5.5 MB @oxc-parser/binding-linux-arm64-gnu 2.2 MB 19
@unhead/cli 22 5.7 MB @oxc-parser/binding-linux-arm64-gnu 2.2 MB 19
@unhead/eslint-plugin 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/react 18 5.5 MB @oxc-parser/binding-linux-arm64-gnu 2.2 MB 19
@unhead/schema-org 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/solid-js 18 5.5 MB @oxc-parser/binding-linux-arm64-gnu 2.2 MB 19
@unhead/svelte 18 5.5 MB @oxc-parser/binding-linux-arm64-gnu 2.2 MB 19
@unhead/vue 18 5.5 MB @oxc-parser/binding-linux-arm64-gnu 2.2 MB 19
unhead 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
Skipped optional dependencies (114)
  • @unhead/bundler: oxc-parser -> @oxc-parser/binding-android-arm-eabi, oxc-parser -> @oxc-parser/binding-android-arm64, oxc-parser -> @oxc-parser/binding-darwin-arm64, oxc-parser -> @oxc-parser/binding-darwin-x64, oxc-parser -> @oxc-parser/binding-freebsd-x64, oxc-parser -> @oxc-parser/binding-linux-arm-gnueabihf, oxc-parser -> @oxc-parser/binding-linux-arm-musleabihf, oxc-parser -> @oxc-parser/binding-linux-arm64-musl, oxc-parser -> @oxc-parser/binding-linux-ppc64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-musl, oxc-parser -> @oxc-parser/binding-linux-s390x-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-musl, oxc-parser -> @oxc-parser/binding-openharmony-arm64, oxc-parser -> @oxc-parser/binding-wasm32-wasi, oxc-parser -> @oxc-parser/binding-win32-arm64-msvc, oxc-parser -> @oxc-parser/binding-win32-ia32-msvc, oxc-parser -> @oxc-parser/binding-win32-x64-msvc
  • @unhead/cli: oxc-parser -> @oxc-parser/binding-android-arm-eabi, oxc-parser -> @oxc-parser/binding-android-arm64, oxc-parser -> @oxc-parser/binding-darwin-arm64, oxc-parser -> @oxc-parser/binding-darwin-x64, oxc-parser -> @oxc-parser/binding-freebsd-x64, oxc-parser -> @oxc-parser/binding-linux-arm-gnueabihf, oxc-parser -> @oxc-parser/binding-linux-arm-musleabihf, oxc-parser -> @oxc-parser/binding-linux-arm64-musl, oxc-parser -> @oxc-parser/binding-linux-ppc64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-musl, oxc-parser -> @oxc-parser/binding-linux-s390x-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-musl, oxc-parser -> @oxc-parser/binding-openharmony-arm64, oxc-parser -> @oxc-parser/binding-wasm32-wasi, oxc-parser -> @oxc-parser/binding-win32-arm64-msvc, oxc-parser -> @oxc-parser/binding-win32-ia32-msvc, oxc-parser -> @oxc-parser/binding-win32-x64-msvc
  • @unhead/react: oxc-parser -> @oxc-parser/binding-android-arm-eabi, oxc-parser -> @oxc-parser/binding-android-arm64, oxc-parser -> @oxc-parser/binding-darwin-arm64, oxc-parser -> @oxc-parser/binding-darwin-x64, oxc-parser -> @oxc-parser/binding-freebsd-x64, oxc-parser -> @oxc-parser/binding-linux-arm-gnueabihf, oxc-parser -> @oxc-parser/binding-linux-arm-musleabihf, oxc-parser -> @oxc-parser/binding-linux-arm64-musl, oxc-parser -> @oxc-parser/binding-linux-ppc64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-musl, oxc-parser -> @oxc-parser/binding-linux-s390x-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-musl, oxc-parser -> @oxc-parser/binding-openharmony-arm64, oxc-parser -> @oxc-parser/binding-wasm32-wasi, oxc-parser -> @oxc-parser/binding-win32-arm64-msvc, oxc-parser -> @oxc-parser/binding-win32-ia32-msvc, oxc-parser -> @oxc-parser/binding-win32-x64-msvc
  • @unhead/solid-js: oxc-parser -> @oxc-parser/binding-android-arm-eabi, oxc-parser -> @oxc-parser/binding-android-arm64, oxc-parser -> @oxc-parser/binding-darwin-arm64, oxc-parser -> @oxc-parser/binding-darwin-x64, oxc-parser -> @oxc-parser/binding-freebsd-x64, oxc-parser -> @oxc-parser/binding-linux-arm-gnueabihf, oxc-parser -> @oxc-parser/binding-linux-arm-musleabihf, oxc-parser -> @oxc-parser/binding-linux-arm64-musl, oxc-parser -> @oxc-parser/binding-linux-ppc64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-musl, oxc-parser -> @oxc-parser/binding-linux-s390x-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-musl, oxc-parser -> @oxc-parser/binding-openharmony-arm64, oxc-parser -> @oxc-parser/binding-wasm32-wasi, oxc-parser -> @oxc-parser/binding-win32-arm64-msvc, oxc-parser -> @oxc-parser/binding-win32-ia32-msvc, oxc-parser -> @oxc-parser/binding-win32-x64-msvc
  • @unhead/svelte: oxc-parser -> @oxc-parser/binding-android-arm-eabi, oxc-parser -> @oxc-parser/binding-android-arm64, oxc-parser -> @oxc-parser/binding-darwin-arm64, oxc-parser -> @oxc-parser/binding-darwin-x64, oxc-parser -> @oxc-parser/binding-freebsd-x64, oxc-parser -> @oxc-parser/binding-linux-arm-gnueabihf, oxc-parser -> @oxc-parser/binding-linux-arm-musleabihf, oxc-parser -> @oxc-parser/binding-linux-arm64-musl, oxc-parser -> @oxc-parser/binding-linux-ppc64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-musl, oxc-parser -> @oxc-parser/binding-linux-s390x-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-musl, oxc-parser -> @oxc-parser/binding-openharmony-arm64, oxc-parser -> @oxc-parser/binding-wasm32-wasi, oxc-parser -> @oxc-parser/binding-win32-arm64-msvc, oxc-parser -> @oxc-parser/binding-win32-ia32-msvc, oxc-parser -> @oxc-parser/binding-win32-x64-msvc
  • @unhead/vue: oxc-parser -> @oxc-parser/binding-android-arm-eabi, oxc-parser -> @oxc-parser/binding-android-arm64, oxc-parser -> @oxc-parser/binding-darwin-arm64, oxc-parser -> @oxc-parser/binding-darwin-x64, oxc-parser -> @oxc-parser/binding-freebsd-x64, oxc-parser -> @oxc-parser/binding-linux-arm-gnueabihf, oxc-parser -> @oxc-parser/binding-linux-arm-musleabihf, oxc-parser -> @oxc-parser/binding-linux-arm64-musl, oxc-parser -> @oxc-parser/binding-linux-ppc64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-musl, oxc-parser -> @oxc-parser/binding-linux-s390x-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-musl, oxc-parser -> @oxc-parser/binding-openharmony-arm64, oxc-parser -> @oxc-parser/binding-wasm32-wasi, oxc-parser -> @oxc-parser/binding-win32-arm64-msvc, oxc-parser -> @oxc-parser/binding-win32-ia32-msvc, oxc-parser -> @oxc-parser/binding-win32-x64-msvc

Production dependencies only. Peer dependencies and Unhead workspace packages are excluded. Skipped optional dependencies are unavailable on the CI platform.


⚡ Performance (directional)

No significant change (within CI noise)

All benchmarks (25)
Benchmark PR Δ RME
SSR render (CPU) 0.358 ms ~ noise ±9.7%
SSR render (wall) 0.263 ms ~ noise ±5.0%
SSR allocated / render 237.9 KiB ~ noise ±7.0%
Schema.org cached render (CPU) 0.340 ms ~ noise ±6.6%
Schema.org cached render (wall) 0.243 ms ~ noise ±2.9%
Schema.org cached allocated / render 133.8 KiB ~ noise ±1.0%
Streaming wrapStream drain (CPU) 0.231 ms ~ noise ±4.2%
Streaming wrapStream drain (wall) 0.146 ms ~ noise ±4.3%
Streaming allocated / drain 149.7 KiB ~ noise ±0.3%
Streaming suspense chunk (CPU) 0.012 ms ~ noise ±8.5%
Streaming allocated / suspense chunk 5.1 KiB ~ noise ±0.4%
CSR DOM mutations / nav 38 ~ noise
CSR re-render (CPU) 0.801 ms ~ noise ±5.5%
CSR re-render (wall) 0.459 ms ~ noise ±2.3%
Bundler: transformInclude mixed ids 0.385 ms ~ noise ±0.3%
Bundler: useSeoMetaTransform static calls 3.750 ms ~ noise ±3.6%
Bundler: minifyTransform inline script/style 0.590 ms ~ noise ±2.6%
Bundler: treeshakeServerComposables many calls 3.046 ms ~ noise ±7.0%
Bundler: treeshakeServerComposables skip unrelated code 0.002 ms ~ noise ±0.4%
Bundler: ssrStaticReplace many head.ssr reads 1.821 ms ~ noise ±5.7%
Bundler: ssrStaticReplace skip unrelated code 0.002 ms ~ noise ±1.1%
Bundler: createHeadTransform many createHead calls 0.667 ms ~ noise ±4.5%
Bundler: react streaming skip JSX without head calls 0.002 ms ~ noise ±1.1%
Bundler: react streaming transform JSX with head calls 1.697 ms ~ noise ±6.7%
Bundler: solid streaming skip JSX without head calls 0.003 ms ~ noise ±1.2%

Baseline: main @ 6b2c5b9 · 2026-07-29 · gzipped is the headline size metric · perf is directional (shared-runner, gated)

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

🧹 Nitpick comments (2)
packages/vue/test/unit/dom/reactivePush.test.ts (2)

14-21: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Dispose the entry created by the first test.

The returned entry is discarded, so its watchEffect remains active after the assertions. Retain it and call dispose() during cleanup; Vue documents the returned watcher handle as the mechanism for stopping the effect. (vuejs.org)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vue/test/unit/dom/reactivePush.test.ts` around lines 14 - 21, Retain
the entry returned by renderDOMHead in the first test and call its dispose()
method during test cleanup after the assertions. Update the reactivePush test
setup without changing the existing title update expectations.

14-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the raw getter branch.

The implementation has a separate typeof input === 'function' path, but the tests only pass computed(...). Add a case using head.push(() => ({ title: title.value })) so raw getter reactivity and disposal are protected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vue/test/unit/dom/reactivePush.test.ts` at line 14, Add a test case
in the reactive push tests that calls head.push with a raw getter function
returning the title object, rather than computed(...). Verify the getter remains
reactive and is properly disposed, covering the implementation’s typeof input
=== 'function' branch while preserving the existing computed case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/vue/test/unit/dom/reactivePush.test.ts`:
- Around line 14-21: Retain the entry returned by renderDOMHead in the first
test and call its dispose() method during test cleanup after the assertions.
Update the reactivePush test setup without changing the existing title update
expectations.
- Line 14: Add a test case in the reactive push tests that calls head.push with
a raw getter function returning the title object, rather than computed(...).
Verify the getter remains reactive and is properly disposed, covering the
implementation’s typeof input === 'function' branch while preserving the
existing computed case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e9f1598-6e75-4a0d-ab79-b02d16f259b5

📥 Commits

Reviewing files that changed from the base of the PR and between bf71e22 and 4b6662b.

📒 Files selected for processing (2)
  • packages/vue/src/client.ts
  • packages/vue/test/unit/dom/reactivePush.test.ts

@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Addressed both review test gaps in 2f9ab79: the computed-input test now disposes its entry, and a direct getter test covers updates plus watcher cleanup. Targeted 4/4 tests, ESLint, and diff check pass.

@harlan-zw harlan-zw added v3 Targets Unhead v3 or the main branch bug Something isn't working vue Vue adapter and integration labels Jul 24, 2026
@harlan-zw
harlan-zw requested review from kricsleo and removed request for kricsleo July 24, 2026 05:32
The v2 core rewrite removed client-side Vue input resolution while preserving reactive VueHeadClient input types. Resolve Vue values during rendering without adding watcher ownership to raw entries.
@harlan-zw harlan-zw changed the title fix(vue): make raw head entries reactive fix(vue): restore raw head input resolution Jul 24, 2026

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

🧹 Nitpick comments (1)
packages/vue/test/unit/dom/pushResolver.test.ts (1)

40-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover raw getter lifecycle cleanup.

This only tests a computed ref and discards the returned entry. Add a direct head.push(() => …) case that verifies updates, then patch/dispose the entry and confirms it no longer triggers evaluation/rendering. This is required by the PR objective’s watcher-cleanup contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vue/test/unit/dom/pushResolver.test.ts` around lines 40 - 63, Add a
complementary test near the existing computed-input test that pushes a raw
getter via head.push(() => …), verifies its initial and updated
evaluation/rendering behavior, then patches or disposes the returned entry and
confirms subsequent changes no longer evaluate the getter or update the DOM,
covering the watcher-cleanup contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/vue/test/unit/dom/pushResolver.test.ts`:
- Around line 40-63: Add a complementary test near the existing computed-input
test that pushes a raw getter via head.push(() => …), verifies its initial and
updated evaluation/rendering behavior, then patches or disposes the returned
entry and confirms subsequent changes no longer evaluate the getter or update
the DOM, covering the watcher-cleanup contract.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5710fc6b-75fb-4958-ad67-7eb7eaafbee5

📥 Commits

Reviewing files that changed from the base of the PR and between 4b6662b and 6cb0b14.

📒 Files selected for processing (2)
  • packages/vue/src/client.ts
  • packages/vue/test/unit/dom/pushResolver.test.ts

@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Skipping the raw getter lifecycle suggestion. This revision deliberately removes watcher ownership from head.push(). Raw getters resolve lazily when Unhead renders; useHead() owns Vue dependency tracking and cleanup. The computed-input test pins the absence of a raw-entry subscription.

@harlan-zw

Copy link
Copy Markdown
Collaborator Author

🤖 Harlan Agent Kit automated review

This was generated and posted by a robot, not Harlan's personal review.

PASS · 95/100 confidence

  • Reviewed 69c79be0 against main@d9b3d2a6
  • Base current; GitHub reports no conflicts
  • PR title and body match the project template; review threads are resolved
  • Adversarial review found no remaining material code issue
  • Complete diff reviewed; required CI passed 9/9; confidence includes limited local execution

Human merge decision still required.

@harlan-zw harlan-zw changed the title fix(vue): restore raw head input resolution fix(vue): keep reactive input at useHead boundary Jul 29, 2026
…-push

# Conflicts:
#	packages/vue/test/unit/types.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working v3 Targets Unhead v3 or the main branch vue Vue adapter and integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant