feat(unhead): v4 core experiment, compiler-first rebuild - #931
feat(unhead): v4 core experiment, compiler-first rebuild#931harlan-zw wants to merge 50 commits into
Conversation
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.
|
Important Review skippedToo 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (156)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
📦 Bundle Size✅ No notable changes All bundles (14)
📦 Runtime Dependencies✅ No runtime dependency changes All packages (10)
Skipped optional dependencies (19)
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)
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.
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.
Linked issue
Direction and review PR for the v4 experiment.
Type of change
Status
Experiment answered; holding for review, not merging yet. Every
unhead/v4/*and@unhead/vue/v4/*entry is marked@experimentaland semver-exempt. Existing v3 imports are untouched.Known-broken for real sites (adoption blockers, not PR blockers; details in
V4_TRIAL.md):ssrStreamingis not ported.@nuxt/scripts/useScriptdepends on the v3 hook bus.templateParamspassed insideuseHeadinput does not resolve (useuseTemplateParams).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.mdrecords the architecture and measured decisions.packages/unhead/V4_TRIAL.mdcontains the Nuxt trial recipe and current interop limits.Architecture
(weight, order)sort.useHeadobjects into tags in one pass. Capo weights, dedupe identities, prop normalization, and escaping live here.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
slots.flat()without arrayable metacompileEntry+15.1%; keyless identity 9.8xinnerHTMLreadsCompiler-free paths
emitSSRRoutePlanperforms 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.Strict compiled profile sizes (after the sealed-core round below):
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/compiledby 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.mdsection 15.Sealed core (c3e5ca2). The compiled profiles shared
createCoreand 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-freecreateSealedCoreplus 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: sharingattachDomvia 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).
V4PlanTransformnow reports per-call-site outcomes viareportEntry, andcanUseCompiledProfile(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/satisfiesunwrapping and staticuseSeoMetacompilation. Bundler suite 169 to 182 tests.Route premerge primitives (d3b00ea).
emitRouteHeadpremerges app/layout/route-rule/page heads into one route plan (refusing to sealtitleTemplateunless every title source is premerged);recordRouteHeadclassifies 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-onlyuseHead.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/clientalone 5,555 B; the full adapter import set 6,195 B, sinceclient.tsunconditionally 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.mdsections 16-17.Reactive holes (6f7527f, landed).
() => exprgetters in value positions no longer bail the wholeuseHeadcall 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 withonMounted/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, andrecordRouteHead'sstatickind never fires under Nuxt because the generated options always registerTemplateParamsPlugin.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/scriptswas disabled on both builds becauseuseScriptstill 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.
/p50 / p95Nitro 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
Core SEO metadata survived: canonical URLs, viewport, descriptions, and Open Graph text. Current deployment blockers remain explicit:
%siteNameremains because the site passestemplateParamsinsideuseHead.The site repository was never committed, reset, checked out, or discarded.
Correctness and safety
renderDOMHeadcompatibility entry remains covered by a focused Vue test.Validation
unhead,@unhead/vue, and@unhead/bundlerbuild.Benchmarks and size fixtures live under
bench/v4-*. The real-site method, concessions, correctness findings, and rollback recipe are inpackages/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.