Skip to content

feat(unhead): v4 core experiment, compiler-first rebuild - #931

Open
harlan-zw wants to merge 50 commits into
mainfrom
v4/core-experiment
Open

feat(unhead): v4 core experiment, compiler-first rebuild#931
harlan-zw wants to merge 50 commits into
mainfrom
v4/core-experiment

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Direction and review PR for the v4 experiment.

Type of change

  • Documentation
  • Bug fix
  • Enhancement
  • New feature
  • Chore
  • Breaking change

Status

Experiment answered; holding for review, not merging yet. Every unhead/v4/* and @unhead/vue/v4/* entry is marked @experimental and semver-exempt. Existing v3 imports are untouched.

Known-broken for real sites (adoption blockers, not PR blockers; details in V4_TRIAL.md):

  • ssrStreaming is not ported.
  • @nuxt/scripts / useScript depends on the v3 hook bus.
  • templateParams passed inside useHead input does not resolve (use useTemplateParams).
  • schema.org's plugin uses v3 hooks and emits an empty JSON-LD script under v4.

What

This is a working v4 vertical slice for Unhead: strict core, loose-input compiler, SSR and DOM renderers, Vue adapter, package exports, build-time plan emitter, strict compiled profiles, and an opt-in bundler transform.

The implementation lives behind unhead/v4/* and @unhead/vue/v4/* subpaths. Existing v3 imports are unchanged. packages/unhead/V4_DESIGN.md records the architecture and measured decisions. packages/unhead/V4_TRIAL.md contains the Nuxt trial recipe and current interop limits.

Architecture

  • L0 strict core accepts normalized tags or compiled plans. Tags use numeric ids, bit flags, and one (weight, order) sort.
  • L1 turns loose useHead objects into tags in one pass. Capo weights, dedupe identities, prop normalization, and escaping live here.
  • Optional subpaths provide plugins, SEO meta, Early Hints, plan emission, direct SSR route rendering, and compiled-only client/server heads.
  • Plugins are typed slots with copy-on-write ctx.patch. The v3 hook bus is absent from the core.

Compiled plans are branded. Strict compiled heads reject loose input and runtime plugins, so a plan cannot accidentally bypass a plugin that expects loose props.

Performance follow-up

Profiling the real Nuxt response identified L1 as the remaining default-path cost. This round made targeted changes and added compiler-free deployment paths.

Default runtime

change result cost
avoid slots.flat() without arrayable meta fresh resolve +14.8% with identity fast path included below
skip keyed-meta regex work without a key compileEntry +15.1%; keyless identity 9.8x core + compiler + server was 3 B gzip smaller before script guard
skip regex replacement for safe scripts 1.1 MB payload compile 27.091 to 4.647 ms per 600, 5.83x +4 B gzip
resolve Vue input once at SSR compile boundary typical Vue SSR request +3.92% server +16 B gzip
avoid irrelevant DOM innerHTML reads 2,000-meta adoption 3.45 to 3.30 ms client +11 B gzip

Compiler-free paths

emitSSRRoutePlan performs route-level sort and dedupe at build time. Its 602 B gzip renderer fills strings directly into the five SSR payload buckets, with no head instance, map, compiler, plugin slots, sort, or dedupe table.

path median throughput runtime gzip
stateful static plan 121,959 ops/s 4,071 B
direct static route plan 1,797,063 ops/s 602 B
stateful plan with holes 112,172 ops/s 4,071 B
direct route plan with holes 1,333,589 ops/s 602 B
fully static route payload no runtime call 0 B

Strict compiled profile sizes (after the sealed-core round below):

bundle default compiled saved
core client 5,257 B 3,337 B 1,920 B
core server 4,071 B 1,744 B 2,327 B
Vue client plus composable 5,555 B 3,812 B 1,743 B
Vue server 4,514 B 2,099 B 2,415 B

The bundler transform compiles supported static useHead() objects to hoisted plans. It is opt-in through { experimental: { v4Plans: { profile: 'compiled' } } } and trusts only @unhead/vue/v4/compiled by default. Unsupported, shadowed, or structurally dynamic calls stay on the runtime path. Representative server throughput moved from 332,041 to 686,196 ops/s, 2.07x, for +40 B gzip.

Client transformation remains disabled unless requested. Combining plans with the loose client costs 958 B gzip and misses the purpose of the compiled profile.

Byte forensics round, 2026-08-05

Four bounded investigations re-measured every size claim and removed dead bytes. Details in V4_DESIGN.md section 15.

Sealed core (c3e5ca2). The compiled profiles shared createCore and the loose DOM renderer; symbol attribution showed about a quarter of each compiled bundle was unreachable (plugin slots, ResolveCtx, loose-tag dispatch, event binding). A plugin-free createSealedCore plus a self-contained sealed renderer produced the table above: core compiled client 4,384 to 3,337 B, Vue 4,868 to 3,812 B gzip. Measured rejection: sharing attachDom via a renderer parameter costs +981 B because esbuild retains default-parameter initializers.

Compiled-profile gating (b9ba649). Import rewriting cannot be single-pass (bundlers resolve imports before whole-graph compile success is known). V4PlanTransform now reports per-call-site outcomes via reportEntry, and canUseCompiledProfile(stats) gates a second pass that aliases untouched app source at the compiled client. Measured on a real two-pass Vite build: 7,752 to 4,595 B gzip, -40.7%. Also landed: as const/satisfies unwrapping and static useSeoMeta compilation. Bundler suite 169 to 182 tests.

Route premerge primitives (d3b00ea). emitRouteHead premerges app/layout/route-rule/page heads into one route plan (refusing to seal titleTemplate unless every title source is premerged); recordRouteHead classifies prerendered routes static vs dynamic. Premerged glue is not smaller than loose objects (299 vs 345 B gz on a 4-source corpus); its value is proving no loose entries exist on a route, the precondition for compiled-only bundles and 0 B static routes. The Nuxt scanner that extracts route sources from real apps is future work, as is enforcing that static routes have no client-only useHead.

Measurement audit. Independent harnesses reproduced the core loose, core compiled, and Vue compiled figures within noise. Two corrections: the Vue loose number depends on the entry (@unhead/vue/v4/client alone 5,555 B; the full adapter import set 6,195 B, since client.ts unconditionally imports the compiler), and the real-site source-map proxy inflates absolutes by one newline per mapped segment; byte-exact methods measure 6,033-6,081 B (v4) vs 8,039-8,076 B (v3). The ~2.0 kB real-site saving is robust across three attribution methods. App-level marginal cost with Vue bundled: v4 loose is only -157 B vs v3, while v4 compiled is -2,461 B, verified against real transform output.

Reactive holes and prerender tracing, 2026-08-05

Two prototypes attacking the remaining gap: reactive apps could not use the compiled path, and route-aware compilation had no scanner. Details in V4_DESIGN.md sections 16-17.

Reactive holes (6f7527f, landed). () => expr getters in value positions no longer bail the whole useHead call to loose. The transform emits a hoisted sealed plan with holes plus a call-site fills thunk that keeps closing over component scope; the compiled Vue composable watches the getter and patches fills with no recompile. A hole cannot represent an omitted attribute, so non-string fills throw in dev and fail loudly in the core fill contract in production. Measured: a representative reactive page (title + two ref-backed metas) drops from 6,252 B loose to 4,351 B gz compiled, -30.4%, via real transform output; fixed composable cost +117 B. 21 new tests.

Prerender trace as scanner (4d8eb96, prototype in the trial example). Instead of statically analyzing the Nuxt app, record head entries during prerender, render each route twice, and hash: equal marks the route deterministic and bakes its payload into route-head-manifest.json. SSR reproducibility is not client safety, so an AST scan disqualifies routes with onMounted/watch/client-guarded head calls, loudly, with the call site named. Runtime omission for an eligible route measures -8,909 B gz (-29.9%) with Vue bundled. Honest limits recorded: hardcoded route mapping, heuristic scanner, no dynamic params or Suspense-race coverage, and recordRouteHead's static kind never fires under Nuxt because the generated options always register TemplateParamsPlugin.

Real site trial

Tested on unhead.unjs.io with Nuxt 4.5.1, @nuxtjs/seo, schema.org, nuxt-og-image, 104 client chunks, NITRO_PRESET=node-server, warm servers, and hidden source maps. @nuxt/scripts was disabled on both builds because useScript still depends on the v3 hook bus.

SSR performance

The final run used a quiet machine, alternating builds and routes. The CPU result combines two agreeing v4 profiles and 1,800 measured requests.

measurement v3 v4 change
full workload throughput 7.198 req/s 7.935 req/s +10.24%
attributed head self time 1.399 ms/request 1.194 ms/request -14.63%
/ p50 / p95 117.156 / 134.094 ms 100.233 / 122.319 ms -14.45% / -8.78%
docs p50 / p95 130.714 / 162.003 ms 110.851 / 125.990 ms -15.20% / -22.23%

Nitro emitted null source-map mappings for the inlined v4 core, so v4 attribution uses audited generated-code ranges. An earlier v4 profile was unusually low; the two final repeats measured 1.206 and 1.171 ms/request. The real-site evidence supports the end-to-end gain. The isolated compiler benchmark supports the large-script guard.

Browser and bundle

measurement v3 v4
home response end to Vue mount p50 109.1 ms 106.1 ms
docs response end to Vue mount p50 93.1 ms 87.5 ms
home median head mutation records 4 4
docs median head mutation records 8 6
Unhead mapped client bytes, raw 26,264 B 17,034 B
Unhead compression proxy 8,664 B 6,643 B
total client gzip 735,920 B 735,808 B

Core SEO metadata survived: canonical URLs, viewport, descriptions, and Open Graph text. Current deployment blockers remain explicit:

  • %siteName remains because the site passes templateParams inside useHead.
  • schema.org installs an empty invalid JSON-LD script because its plugin uses v3 hooks.
  • runtime benchmark builds emitted no OG image on either version; v4 prerender survival remains unproven.
  • v4 HTML is about 60 kB larger raw and 3 kB larger gzip because the v3 SEO minify hook decodes Nuxt safety escapes. The head delta is about 750 B.

The site repository was never committed, reset, checked out, or discarded.

Correctness and safety

  • A dual-path corpus requires runtime compilation and emitted plans to render byte-identically.
  • The plan emitter rejects dynamic identities, unsupported title templates, unsafe hole positions, and non-string interpolation semantics.
  • Literal private-use characters cannot collide with emitter hole tokens.
  • Empty html/body attributes no longer emit phantom fragments.
  • First dirty DOM flush with zero tags skips adoption; relevant scripts and styles still preserve inner HTML.
  • Nuxt's renderDOMHead compatibility entry remains covered by a focused Vue test.
  • The bundler transform tracks aliases, namespaces, and lexical shadowing, preserves source maps, and bails on unsupported syntax.

Validation

  • 524 focused v4, Vue, DOM, emitter, direct-renderer, route, and correctness tests pass.
  • 182 bundler tests pass.
  • Typecheck and lint pass.
  • unhead, @unhead/vue, and @unhead/bundler build.
  • all 70 import-inert checks pass.
  • package export snapshots and both package checks pass.

Benchmarks and size fixtures live under bench/v4-*. The real-site method, concessions, correctness findings, and rollback recipe are in packages/unhead/V4_TRIAL.md.

Review boundary

This remains an experimental, draft v4 surface. The main decisions to review are the compiler boundary, branded plan contract, strict compiled profiles, direct SSR route format, and plugin compatibility model. Existing v3 consumers are unaffected.

L0 strict core (plans/holes/dedupe/sort/COW resolve slots), L1 loose-input
compiler, SSR renderer. Parity suite byte-identical vs v3.

Bench (ssr typical page e2e vs v3): runtime objects 1.66x, compiled plans
3.8x, sealed route plan 5.6x. Bundle: L0+L1 4144B gz (v3 4973), sealed L0
1878B gz. Design doc: packages/unhead/V4_DESIGN.md
No per-call weight-map allocation, no user-input mutation, PURE-annotated
tables. Parity suite still byte-identical. 9296->8728B min (4144->4039 gz).
…effects

Zero-work init (adoption deferred to first flush), append-only insertion,
microtask-batched renders via injectable scheduler, side effects as keyed
[kind,target,key] records undone by switch.

DOM state matches v3 for the shared workload (sorted-set comparison; head
order is a designed capo difference). Bench: mount+dispose 2.98x vs v3,
patch+rerender 1.11x. Client bundle 5076B gz (v3 5483).
Single-pass compileTag (normalizeProps+makeTag+identity+weight folded),
sorted-array resolve cache (repeated resolve 1.5x), null-props crash fix
for content-only tags, og:image array sub-property adjacency restored via
(w,o) re-sort on arrayable appends (OG spec + v3 sortFlatMeta parity).

Server bundle 4039->3818B gz, sealed 1878->1851B gz. Reverted with data:
bitmask membership tables (+13 gz), identity inlining (+25 gz).
Lockstep prefix/suffix diff against previous render's effects; seen-set
only materializes on structural divergence. Reference-stable Tag skip via
element stash. patch+rerender 2.4-3.5x vs v3, 50-patch flush 161x,
no-op rerender ~free. Client 5076->4976B gz. 6 new reclaim edge tests.
…onical

useSeoMeta byte-equal to v3 (alias table, robots/CSP packing, media object
ordering, patch renormalization). Three v3 plugins as copy-on-write resolve
slots; v4 with all 3 plugins renders 2.6x faster than v3 equivalent.
35 compat tests. templateParams moves to useTemplateParams(head, params).
toEarlyHints/toLinkHeader from live heads or static route plans (zero
per-request resolve on the plan path). preload/preconnect only, nonce skip,
rel+href dedupe, capo order, CRLF/scheme injection hardening. 15 tests.
+425B gz marginal; entry without SSR renderer is net-smaller than server
fixture. No other head manager can emit hints from build-time plans.
…ch warn

Design doc section 12 items 1-4: ctx.shared reinstated (TitlePlugin publishes
raw+resolved title as L1 contract), dev-only warn on positional-tag patch
(0B prod via NODE_ENV DCE), ctx.each flat iteration, entry/tags plugin slots
with registration-cliff invalidation (plan revival now lazy at resolve).
+118B gz server (budget 120). 77 tests.

Also: fillHoles hardening from emitter findings - text mode now matches the
SSR title escaping contract exactly (dual-path law), json mode escapes
backslash+quote so fills cannot corrupt the JSON document.
emitEntryPlan (holes as PUA tokens through the real compile pipeline, so
dual-path parity holds by construction), emitRoutePlan (full L1+dedupe+title
fold at build, true d/w kept so runtime entries override), planToCode,
PlanEmitError for deterministic bundler bails. 60 tests: 37-item dual-path
corpus byte-equal, hole escaping/ordering, route fold + runtime override.

Wire-format findings recorded for the spec: PlanTag needs an arrayable flag
slot, attr fragments need class folding, weight freezes at emit for
weight-feeding hole props.
Revived plan tags lost F_ARRAYABLE, so same-d og:image tuples
dedupe-replaced instead of arrayable-appending, and the emitter had to
concat-fold arrayable groups and bail on interleaved ones (og:image +
sub-prop sets could not seal). Wire format v1 folds the flag into the
existing pos number: pf = pos | 8, pf & 7 is the position, tuple arity
unchanged. revivePlan decodes bit 3 into F_ARRAYABLE; emitEntryPlan and
emitRoutePlan emit per-tag tuples with the flag.
Sealed class/style fragments carried a coarse d (htmlAttrs:class) while
the runtime path explodes per token (htmlAttrs:class:dark), so core
dedupe never saw sealed-vs-runtime collisions and renderSSRHead emitted
a duplicate class attribute (invalid HTML). Root fix is identity
alignment: attr fragments emit per prop and per token with the runtime
d, and renderSSRHead parses single-attr prebuilt fragments back into
the attr bag so both paths render through one propsToString call.
Token union for class and later-entry-wins per style property now fall
out of ordinary core dedupe.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 156 files, which is 6 over the limit of 150.

To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 26eded5a-e7be-4e14-9d35-139208235117

📥 Commits

Reviewing files that changed from the base of the PR and between fdc111c and 359a3b1.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (156)
  • bench/v4-client-extra.bench.ts
  • bench/v4-client-reclaim.test.ts
  • bench/v4-compat.bench.ts
  • bench/v4-compat.test.ts
  • bench/v4-compile-script.bench.ts
  • bench/v4-compile-script.test.ts
  • bench/v4-compiled-profiles.bench.ts
  • bench/v4-compiled-profiles.test.ts
  • bench/v4-core.bench.ts
  • bench/v4-dom.bench.ts
  • bench/v4-dom.test.ts
  • bench/v4-early-hints.test.ts
  • bench/v4-emit.test.ts
  • bench/v4-explore/demo/README.md
  • bench/v4-explore/demo/build.mjs
  • bench/v4-explore/demo/measure.browser.js
  • bench/v4-explore/demo/src/buildtime.ts
  • bench/v4-explore/demo/src/client-v3.ts
  • bench/v4-explore/demo/src/client-v4-sealed.ts
  • bench/v4-explore/demo/src/client-v4.ts
  • bench/v4-explore/demo/src/entries.ts
  • bench/v4-explore/demo/src/runner.ts
  • bench/v4-explore/demo/summarize.mjs
  • bench/v4-explore/hydration/client-core.ts
  • bench/v4-explore/hydration/clients.ts
  • bench/v4-explore/hydration/hydrate.bench.ts
  • bench/v4-explore/hydration/hydrate.test.ts
  • bench/v4-explore/hydration/servers.ts
  • bench/v4-explore/hydration/shared.ts
  • bench/v4-explore/nav/client-plan.ts
  • bench/v4-explore/nav/core-swap.ts
  • bench/v4-explore/nav/dom-ops.ts
  • bench/v4-explore/nav/nav.bench.ts
  • bench/v4-explore/nav/nav.test.ts
  • bench/v4-explore/nav/routes.ts
  • bench/v4-explore/nav/strategies.ts
  • bench/v4-explore/nuxt/NUXT_INTEGRATION.md
  • bench/v4-explore/nuxt/nuxt-lifecycle.test.ts
  • bench/v4-explore/nuxt/suspense-overlap.test.ts
  • bench/v4-explore/vue-native/RESEARCH.md
  • bench/v4-explore/vue-native/measure-bytes.mjs
  • bench/v4-explore/vue-native/nav-loop.bench.ts
  • bench/v4-explore/vue-native/nav-ops.test.ts
  • bench/v4-explore/vue-native/proto/server-seam.ts
  • bench/v4-explore/vue-native/proto/vnode-client.ts
  • bench/v4-explore/vue-native/proto/vue-attrs.ts
  • bench/v4-explore/vue-native/reactivity.test.ts
  • bench/v4-explore/vue-native/route-fixture.ts
  • bench/v4-explore/vue-native/shared-fns.test.ts
  • bench/v4-explore/vue-native/sizes/client-baseline.ts
  • bench/v4-explore/vue-native/sizes/client-compiled.ts
  • bench/v4-explore/vue-native/sizes/client-vue-compiled.ts
  • bench/v4-explore/vue-native/sizes/client-vue-runtime.ts
  • bench/v4-explore/vue-native/sizes/client-vue.ts
  • bench/v4-explore/vue-native/sizes/listy-v4.ts
  • bench/v4-explore/vue-native/sizes/listy-vue.ts
  • bench/v4-explore/vue-native/sizes/server-baseline.ts
  • bench/v4-explore/vue-native/sizes/server-compiled.ts
  • bench/v4-explore/vue-native/sizes/server-route-plan.ts
  • bench/v4-explore/vue-native/sizes/server-seam.ts
  • bench/v4-explore/vue-native/sizes/server-vue-compiled.ts
  • bench/v4-explore/vue-native/sizes/server-vue-runtime.ts
  • bench/v4-explore/vue-native/sizes/server-vue.ts
  • bench/v4-explore/vue-native/ssr-attrs.test.ts
  • bench/v4-explore/vue-native/ssr.bench.ts
  • bench/v4-explore/vue-native/vnode-renderer.test.ts
  • bench/v4-parity.test.ts
  • bench/v4-perf-audit.test.ts
  • bench/v4-reactive-holes-sizes.report.test.ts
  • bench/v4-route-premerge-sizes.report.test.ts
  • bench/v4-routes.test.ts
  • bench/v4-server-hotpath.bench.ts
  • bench/v4-server-plans.bench.ts
  • bench/v4-server-plans.test.ts
  • bench/v4/fixtures.ts
  • bench/vitest.config.ts
  • examples/nuxt-v4-trial/README.md
  • examples/nuxt-v4-trial/app/app.vue
  • examples/nuxt-v4-trial/app/pages/about.vue
  • examples/nuxt-v4-trial/app/pages/index.vue
  • examples/nuxt-v4-trial/app/pages/trap.vue
  • examples/nuxt-v4-trial/app/plugins/v4-head-trace.server.ts
  • examples/nuxt-v4-trial/bench/measure-runtime-omission.mjs
  • examples/nuxt-v4-trial/bench/with-runtime.entry.ts
  • examples/nuxt-v4-trial/bench/without-runtime.entry.ts
  • examples/nuxt-v4-trial/module/head-trace-registry.ts
  • examples/nuxt-v4-trial/module/head-trace.test.ts
  • examples/nuxt-v4-trial/module/head-trace.ts
  • examples/nuxt-v4-trial/module/scan-client-only-head.test.ts
  • examples/nuxt-v4-trial/module/scan-client-only-head.ts
  • examples/nuxt-v4-trial/nuxt.config.ts
  • examples/nuxt-v4-trial/package.json
  • examples/nuxt-v4-trial/server/plugins/v4-head-manifest.ts
  • examples/nuxt-v4-trial/shims/unhead-utils.mjs
  • examples/nuxt-v4-trial/vitest.config.ts
  • packages/bundler/build.config.ts
  • packages/bundler/src/unplugin/V4PlanTransform.ts
  • packages/bundler/src/unplugin/framework.ts
  • packages/bundler/src/unplugin/types.ts
  • packages/bundler/src/unplugin/vite.ts
  • packages/bundler/test/v4PlanTransform.audit.test.ts
  • packages/bundler/test/v4PlanTransform.compiledProfile.test.ts
  • packages/bundler/test/v4PlanTransform.reactiveHoles.test.ts
  • packages/bundler/test/v4PlanTransform.staticShapes.test.ts
  • packages/bundler/test/v4PlanTransform.test.ts
  • packages/react/vitest.config.ts
  • packages/solid-js/vitest.config.ts
  • packages/svelte/vitest.config.ts
  • packages/unhead/V4_DESIGN.md
  • packages/unhead/V4_TRIAL.md
  • packages/unhead/build.config.ts
  • packages/unhead/package.json
  • packages/unhead/src/v4/client-compiled.ts
  • packages/unhead/src/v4/client-plans.ts
  • packages/unhead/src/v4/client.ts
  • packages/unhead/src/v4/compile.ts
  • packages/unhead/src/v4/compiled.ts
  • packages/unhead/src/v4/core-sealed.ts
  • packages/unhead/src/v4/core.ts
  • packages/unhead/src/v4/early-hints.ts
  • packages/unhead/src/v4/emit.ts
  • packages/unhead/src/v4/identity.ts
  • packages/unhead/src/v4/index.ts
  • packages/unhead/src/v4/plugins.ts
  • packages/unhead/src/v4/record.ts
  • packages/unhead/src/v4/seo.ts
  • packages/unhead/src/v4/server-compiled.ts
  • packages/unhead/src/v4/server-plans.ts
  • packages/unhead/src/v4/server.ts
  • packages/vue/build.config.ts
  • packages/vue/package.json
  • packages/vue/src/v4/client-compiled.ts
  • packages/vue/src/v4/client.ts
  • packages/vue/src/v4/compiled.ts
  • packages/vue/src/v4/composables.ts
  • packages/vue/src/v4/index.ts
  • packages/vue/src/v4/install.ts
  • packages/vue/src/v4/plugins.ts
  • packages/vue/src/v4/resolver.ts
  • packages/vue/src/v4/safe.ts
  • packages/vue/src/v4/server-compiled.ts
  • packages/vue/src/v4/server.ts
  • packages/vue/src/v4/types.ts
  • packages/vue/src/v4/utils.ts
  • packages/vue/test/v4/client-compat.test.ts
  • packages/vue/test/v4/compiled.reactiveHoles.test.ts
  • packages/vue/test/v4/compiled.test.ts
  • packages/vue/test/v4/dom.test.ts
  • packages/vue/test/v4/ssr.test.ts
  • packages/vue/test/v4/util.ts
  • packages/vue/vitest.config.ts
  • pnpm-workspace.yaml
  • test/exports/bundler.yaml
  • test/exports/unhead.yaml
  • test/exports/vue.yaml
  • tsconfig.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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 Aug 4, 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.6 kB 8.8 kB 25.2 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.7 kB 9.7 kB 28 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.8 kB 9.8 kB 28.4 kB
React Server (Minimal) 5.2 kB 4.7 kB 13 kB
Schema.org
Schema.org (Minimal) 11.7 kB 10.5 kB 34.2 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 10 786.9 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/cli 18 4.4 MB @oxc-parser/binding-linux-arm64-gnu 2 MB 19
@unhead/eslint-plugin 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/react 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/schema-org 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/solid-js 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/svelte 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/vue 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
unhead 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
Skipped optional dependencies (19)
  • @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

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.363 ms ~ noise ±9.5%
SSR render (wall) 0.263 ms ~ noise ±4.8%
SSR allocated / render 242.6 KiB ~ noise ±5.6%
Schema.org cached render (CPU) 0.361 ms ~ noise ±6.5%
Schema.org cached render (wall) 0.256 ms ~ noise ±2.7%
Schema.org cached allocated / render 139 KiB ~ noise ±0.8%
Streaming wrapStream drain (CPU) 0.248 ms ~ noise ±3.9%
Streaming wrapStream drain (wall) 0.150 ms ~ noise ±4.2%
Streaming allocated / drain 149.3 KiB ~ noise ±0.7%
Streaming suspense chunk (CPU) 0.014 ms ~ noise ±12.2%
Streaming allocated / suspense chunk 5.1 KiB ~ noise ±0.3%
CSR DOM mutations / nav 38 ~ noise
CSR re-render (CPU) 0.842 ms ~ noise ±5.2%
CSR re-render (wall) 0.473 ms ~ noise ±2.1%
Bundler: transformInclude mixed ids 0.404 ms ~ noise ±1.9%
Bundler: useSeoMetaTransform static calls 3.529 ms ~ noise ±4.3%
Bundler: minifyTransform inline script/style 0.513 ms ~ noise ±3.3%
Bundler: treeshakeServerComposables many calls 2.600 ms ~ noise ±5.7%
Bundler: treeshakeServerComposables skip unrelated code 0.002 ms ~ noise ±1.0%
Bundler: ssrStaticReplace many head.ssr reads 1.642 ms ~ noise ±6.3%
Bundler: ssrStaticReplace skip unrelated code 0.002 ms ~ noise ±1.2%
Bundler: createHeadTransform many createHead calls 0.591 ms ~ noise ±5.1%
Bundler: react streaming skip JSX without head calls 0.003 ms ~ noise ±1.4%
Bundler: react streaming transform JSX with head calls 1.580 ms ~ noise ±6.5%
Bundler: solid streaming skip JSX without head calls 0.003 ms ~ noise ±1.2%

Baseline: main @ fdc111c · 2026-08-04 · gzipped is the headline size metric · perf is directional (shared-runner, gated)

revivePlan encodes no type id for head-position tuples, so the client's
f & F_ID dispatch read every prebuilt tag as T_TITLE and clobbered
document.title with escaped html. Port the F_PREBUILT handling from the
nav exploration client: regex tag parse into element sync ops, pos 3/4
single-attr fragments applied to html/body, refill syncs only changed
attrs on the adopted element, changed scripts replaced never mutated.
TitlePlugin read a sealed title's c as raw text, but F_PREBUILT c is the
full '<title>...</title>' html, so any runtime titleTemplate over a
sealed title leaked the template outside the element. Decode the inner
text (unescapeHtml, new core helper next to the escape tables), apply
the template, and demote the patched tag to a plain title so renderers
re-escape the templated text.
Client DOM adoption used a partial identity mirror plus a prop hash, so
base, alternate+hreflang links and keyed metas fell back to the hash and
got re-created instead of adopted on hydration (V4_DESIGN.md 12 known
gap). Export compile's identity() and adopt through it directly
(data-hid stands in for key; measured cost-identical in the hydration
exploration). Keyed metas additionally emit data-hid when the identity
consumed the key so the client can reconstruct meta:<name>:key:<k>;
v3 leaves metas unmarked, a deliberate divergence. Explore tests that
pinned the old gaps now pin the fixed behavior.
useTemplateParams().patch() mutates the side store without touching an
entry, so nothing marked the client head dirty and a params-only change
(Nuxt updating %siteName on route change) never repainted; syncHead's
head.render() no-oped on a clean dirty flag. invalidate() is now public
on V4Head: core drops the resolve cache, the client additionally marks
dirty and schedules a flush.
Section 13: lazy adoption kept (eager 215x worse idle), exact identity
free, markers/manifest rejected default-on (+204-246 B gz for ~30 us),
no-adopt disqualified (script re-execution); entry-patch blessed for
navigation, head.swap rejected (169 B gz, zero DOM-op gain), sealed
plans are a boot/SSR optimization; real-browser numbers table; B1/B2
fix refs and the Nuxt NEEDS-ADDITION list state. Section 12/12.1
resolved markers updated.
Comment thread bench/v4-explore/hydration/servers.ts Fixed
Vue adapter over the v4 core, API-compatible with the surface Nuxt
consumes from @unhead/vue: alias @unhead/vue{,/client,/server,/plugins,/utils}
to the matching @unhead/vue/v4 subpath and a Nuxt site runs on v4.

- createHead client/server wrap unhead/v4, register plugins from Nuxt's
  unhead-options shape, install via the v3 headSymbol
- useHead resolves refs/computeds/getters through walkResolver+VueResolver;
  client is the v3 watchEffect+patch skeleton with dispose-on-unmount and
  KeepAlive deactivation, server pushes a thunk so refs assigned after
  useHead (async setup) still render
- useSeoMeta via the v4 flat-meta expander, useHeadSafe as a pure input
  allowlist filter (v3 SafeInputPlugin port, no plugin, no core bytes)
- client hooks shim: dom:beforeRender gates DOM flushes (Nuxt pause
  pattern), other hook names warn in dev and no-op
- renderSSRHead keeps the v3 payload contract (newline joins,
  omitLineBreaks), byte-identical to v3 for a typical page
- v4/utils ships a v3-shaped resolveTags for Nuxt's prefetch-preload-tags
  plugin (alias unhead/utils)
Real Nuxt 4.5 app running its whole head pipeline on the v4 core through
the @unhead/vue/v4 adapter, wired via the override mechanism a real site
would use: nuxt.options.alias (flows into both vite builds and nitro's
rollup) mapping every @unhead/vue surface Nuxt imports onto the v4 dist,
an unhead/utils shim carrying the v4-shaped resolveTags for the
prefetch-preload-tags plugin, and a tiny module re-emitting the
unhead-options template (the stock one imports legacyPlugins from an
absolute path no alias can match). stream/* and scripts stay on v3.

Proven: nuxt build passes; SSR head of both routes is byte-identical to
a UNHEAD_V3_BASELINE build (titleTemplate, useSeoMeta expansion,
canonical, htmlAttrs); headless-browser run shows zero unhead DOM writes
at hydrate, reactive title updates, and clean add/remove of about-page
tags across NuxtLink navigation with no duplicates. Evidence in the
example README; the real-site recipe (overrides, alias set, known-broken
list, rollback) in packages/unhead/V4_TRIAL.md. The adapter needed no
fixes. Example build is not part of CI.
Compiled profiles previously shared createCore and the loose renderDOM,
retaining plugin slots, ResolveCtx, loose-tag dispatch, and event binding
that sealed plans can never reach. esbuild cannot tree-shake at
sub-function granularity, so roughly a quarter of each compiled bundle
was dead code.

createSealedCore (dedupe/arrayable only) plus a self-contained sealed DOM
renderer, duplicated on purpose: no bundle contains both cores, and
parametrizing attachDom with a renderer argument measured +981 B gz
because the default-parameter initializer retains the loose renderer.

gzip -9, final consumer bundles (bench/v4-explore/vue-native/measure-bytes.mjs):
- core client-compiled: 4,384 -> 3,337 B
- vue client-compiled + composable: 4,868 -> 3,812 B
- core server-compiled: 2,065 -> 1,744 B
- vue server-compiled: 2,416 -> 2,099 B

Loose input still fails at resolve() time, matching createCore's deferred
contract; compiled heads keep rejecting runtime plugins loudly.
emitRouteHead(sources) premerges app, layout, route-rule, and page heads
into a single route plan, refusing to seal titleTemplate unless every
title source is proven premerged (allowTitleTemplate). recordRouteHead
classifies prerendered routes static vs dynamic so deterministic routes
can fold to a final payload.

Premerged glue is not smaller than loose objects at small scale (299 vs
345 B gz on the 4-source corpus); the point of premerge is proving no
loose entries exist on a route, which is the precondition for shipping
compiled-only bundles or 0 B static routes. The Nuxt build-time scanner
that extracts RouteHeadSource[] from real apps is future work.

recordRouteHead reads head._pe?.length: sealed cores (core-sealed.ts)
structurally cannot host plugins, so absent arrays mean none registered.

15 targeted tests including two premerged route plans overlapping under
Suspense navigation; unhead/v4/record subpath is import-inert and passes
attw (typesVersions entry included).
Dead runtime removal cannot be a single-pass import rewrite: Rollup and
Vite resolve imports before whole-graph compile success is known. Ship
the two-pass primitive instead: V4PlanTransform gains a reportEntry
callback reporting per-call-site compile/bail outcomes, and
canUseCompiledProfile(stats) returns true only when every trusted call
site compiled, letting a build alias the app at
@unhead/vue/v4/client-compiled on the second pass.

Measured on a real two-pass Vite build over untouched app source:
7,752 -> 4,595 B gzip (-40.7%, raw -45.2%), stacking on the sealed-core
profiles.

Also: as-const/satisfies annotations are erased before decode so
annotated static heads compile, and static useSeoMeta compiles via the
existing UseSeoMetaTransform -> V4PlanTransform pipeline with a
compiled-profile useSeoMeta alias in @unhead/vue/v4/compiled.

Bundler suite 169 -> 182 tests. Template-literal holes, cross-module
hoisting, and route manifest emission remain designed-only.
Section 15: sealed-core diet numbers, measurement audit correcting the
Vue loose entry ambiguity and the source-map proxy newline artifact,
route premerge findings, compiled-profile gating, and an honest floors
statement. Trial doc notes byte-exact real-site attribution (6,033-6,081
v4 vs 8,039-8,076 v3; the ~2.0 kB saving is method-robust).
Reactive values previously bailed the whole useHead call to the loose L1
path. An arrow with a pure expression body in a value position has fixed
tag structure; only the string varies. The transform now compiles such
calls to a hoisted sealed plan with holes plus a call-site fills thunk
({ fills: () => [x.value] }) built from original source spans, so
getters keep closing over component scope. Block bodies, params, async,
structural positions, and identity/config positions still bail loose.

The compiled Vue composable accepts a fills getter: client pushes once,
watches the getter, and patches the entry with new fills (no recompile,
revivePlan array fast path); SSR evaluates once with no watcher. A hole
can never represent an omitted attribute, so a non-string/number fill
throws in dev naming the hole index, and in production fails inside the
core fill contract rather than being silently dropped.

Measured (esbuild, gzip -9, vue external):
- compiled composable fixed cost: 3,812 -> 3,929 B client (+117 B),
  2,099 -> 2,219 B server
- representative reactive page (title + 2 ref metas): loose 6,252 B ->
  compiled 4,351 B (-30.4%), via real V4PlanTransform output

21 new tests (transform eligibility/bails, DOM refill without dupes,
escape-on-refill, dispose, dev throw, SSR once). V4_DESIGN.md section 16
records mechanism, rejected sub-approaches, and open risks.
Sidesteps the static Nuxt scanner: Nuxt already executes every
prerendered route, so record registered head entries per SSR render,
render each route twice from the nitro plugin's top-level setup (nested
renders trip Nuxt's AsyncLocalStorage loop detector), and hash the head
payload; equal hashes mark the route deterministic and bake its final
payload into route-head-manifest.json.

SSR reproducibility is not client safety: an oxc-parser AST scan
disqualifies routes whose <script setup> reaches head composables only
through onMounted/watch/import.meta.client, naming the callsite in the
manifest. It over-disqualifies by design and never silently passes. The
trap page demonstrates both halves: hash says deterministic, scanner
says runtimeOmittable: false.

Isolated harness measures runtime omission for an eligible route at
29,768 -> 20,859 B gz (-29.9%) with Vue bundled, 7,769 -> 109 B
unhead-only.

Findings recorded honestly: recordRouteHead's static kind never fires in
a real Nuxt app because the unhead-options template always registers
TemplateParamsPlugin, so the hash does the load-bearing determinism
work; module-singleton state splits across the Vite/Nitro build graphs
and needs a globalThis + Symbol.for registry. Route-file mapping is
hardcoded for the trial's 3 routes; dynamic params, Suspense races, and
islands are untested.

15 unit tests in the example workspace. The prior rejection of resumable
head serialization does not apply: this is a build-time decision with
zero wire-format bytes, not a runtime resumption protocol.
@harlan-zw
harlan-zw marked this pull request as ready for review August 5, 2026 04:12
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.

2 participants