Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
743dfb9
feat(unhead): v4 core experiment - L0/L1 server prototype
harlan-zw Aug 3, 2026
5ac3544
perf(v4): compile.ts diet pass - hoist tables, merge class/style walker
harlan-zw Aug 3, 2026
a45bb8e
feat(v4): client DOM renderer - lazy adoption, batching, data-record …
harlan-zw Aug 3, 2026
08bc18b
perf(v4): fold L1 compiler, resolve cache, arrayable adjacency re-sort
harlan-zw Aug 3, 2026
638c9b2
perf(v4): flat stride-3 effect array in DOM renderer
harlan-zw Aug 3, 2026
a55c07c
feat(v4): compat layer - useSeoMeta + templateParams/inferSeoMeta/can…
harlan-zw Aug 3, 2026
4f48dae
feat(v4): 103 Early Hints adapter
harlan-zw Aug 3, 2026
9b3e250
docs(v4): prototype findings - scorecard, slot API revisions, diverge…
harlan-zw Aug 3, 2026
c9226f9
feat(v4): slot API revisions - ctx.shared/each, entry+tags slots, pat…
harlan-zw Aug 3, 2026
cbf9fdc
feat(v4): build-time plan emitter + cross-entry route pre-merge
harlan-zw Aug 3, 2026
9def501
docs(v4): emitter round findings - wire format v1 spec changes
harlan-zw Aug 3, 2026
a557a65
fix(v4): carry the arrayable flag in the PlanTag pos slot (pos | 8)
harlan-zw Aug 4, 2026
8df378a
fix(v4): merge prebuilt attr fragments through the SSR attr bag
harlan-zw Aug 4, 2026
f4a3692
bench(v4): real-browser hydration + navigation demo
harlan-zw Aug 4, 2026
1b621d3
bench(v4): hydration architecture exploration
harlan-zw Aug 4, 2026
8e7493a
docs(v4): nuxt consumer role-play + lifecycle simulation
harlan-zw Aug 4, 2026
f4cff85
bench(v4): page-switch architecture exploration
harlan-zw Aug 4, 2026
953a74e
fix(v4): client renders sealed plan tags (B2)
harlan-zw Aug 4, 2026
e6b4597
fix(v4): TitlePlugin handles prebuilt titles (B1)
harlan-zw Aug 4, 2026
1e63531
feat(v4): exact adoption identity + keyed meta data-hid
harlan-zw Aug 4, 2026
771e0cc
feat(v4): expose invalidate() on the head
harlan-zw Aug 4, 2026
8f389be
docs(v4): record hydration + navigation exploration verdicts
harlan-zw Aug 4, 2026
eabdbc4
perf(v4): split sealed-plan client rendering into client-plans
harlan-zw Aug 4, 2026
0d229ca
bench(v4): demo re-run on shipped client-plans renderer
harlan-zw Aug 4, 2026
9a9c3b9
fix(v4): adopt prebuilt scripts on sealed hydrate instead of replacing
harlan-zw Aug 4, 2026
659c1ba
chore(v4): lint the dev-browser driver, splice markers without replace
harlan-zw Aug 4, 2026
885e29b
feat(v4): package subpath exports
harlan-zw Aug 4, 2026
719a9ce
bench(v4): vue-native renderer reuse research
harlan-zw Aug 4, 2026
70baafc
fix(v4): paren-aware style splitting preserves data-URI values
harlan-zw Aug 4, 2026
00fdbaa
test(v4): update vue-native style pin to fixed paren-aware behavior
harlan-zw Aug 4, 2026
94d09dd
feat(vue): v4 adapter (@unhead/vue/v4)
harlan-zw Aug 4, 2026
ff8aee0
feat(vue): default v4 client scheduler to nextTick
harlan-zw Aug 4, 2026
9432549
feat(examples): nuxt v4 trial app
harlan-zw Aug 4, 2026
7c96a97
fix(vue): restore renderDOMHead compatibility
harlan-zw Aug 4, 2026
a0666e6
test(vue): update v4 client export snapshot
harlan-zw Aug 4, 2026
c173b31
docs(v4): record real site trial
harlan-zw Aug 4, 2026
4ea228b
docs(v4): explain trial response delta
harlan-zw Aug 4, 2026
418f2d9
feat(v4): add compiler-free runtime profiles
harlan-zw Aug 4, 2026
7cf8411
feat(bundler): compile static v4 head plans
harlan-zw Aug 4, 2026
6005f3e
docs(v4): record compiler-free performance results
harlan-zw Aug 4, 2026
25921cd
test(bundler): resolve v4 emitter in framework suites
harlan-zw Aug 4, 2026
c3e5ca2
perf(v4): plugin-free sealed core for compiled profiles
harlan-zw Aug 4, 2026
d3b00ea
feat(v4): route premerge and prerender recording primitives
harlan-zw Aug 5, 2026
b9ba649
feat(bundler): compiled-profile gating and wider static shapes
harlan-zw Aug 5, 2026
e2108a0
docs(v4): record byte forensics and marginal-cost round
harlan-zw Aug 5, 2026
e8520f5
test: update export snapshots for v4 route and compiled seo exports
harlan-zw Aug 5, 2026
6f7527f
feat(bundler,vue): reactive holes compile getter values to sealed plans
harlan-zw Aug 5, 2026
4d8eb96
feat(examples): prerender-trace route head manifest prototype
harlan-zw Aug 5, 2026
b673037
docs(v4): record prerender-trace scanner prototype findings
harlan-zw Aug 5, 2026
359a3b1
docs(v4): mark all v4 subpath entries @experimental
harlan-zw Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions bench/v4-client-extra.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Extra client renderer benches beyond v4-dom.bench.ts:
* - no-op rerender: render() with nothing dirty must be ~free (dirty gate)
* - 50 patches one flush: v4 batches all patches into a single render; the
* v3 comparison does 50 sync renders because that is its real behavior
* (every entries:updated triggers a DOM render)
*/
import { JSDOM } from 'jsdom'
import { bench, describe } from 'vitest'
import { createHead as createV3 } from '../packages/unhead/src/client'
import { createHead as createV4 } from '../packages/unhead/src/v4/client'
import { applyPage } from './v4/fixtures'

const BLANK = '<!DOCTYPE html><html><head></head><body><div><h1>hello</h1></div></body></html>'

describe('dom no-op rerender', () => {
const v3dom = new JSDOM(BLANK)
const v3head = createV3({ document: v3dom.window.document })
applyPage((input, opts) => v3head.push(input, opts))
v3head.render()
bench('v3', () => {
v3head.render()
})

const v4dom = new JSDOM(BLANK)
const v4head = createV4({ document: v4dom.window.document, scheduler: () => {} })
applyPage((input, opts) => v4head.push(input, opts))
v4head.render()
bench('v4', () => {
v4head.render()
})
})

describe('dom 50 patches one flush', () => {
const v3dom = new JSDOM(BLANK)
const v3head = createV3({ document: v3dom.window.document })
applyPage((input, opts) => v3head.push(input, opts))
const v3entry = v3head.push({ title: 'A', meta: [{ name: 'description', content: 'a' }] })
let i3 = 0
bench('v3 (50 sync renders, its real behavior)', () => {
for (let n = 0; n < 50; n++) {
v3entry.patch({ title: `A${i3++}`, meta: [{ name: 'description', content: `a${i3}` }] })
v3head.render()
}
})

const v4dom = new JSDOM(BLANK)
const v4head = createV4({ document: v4dom.window.document, scheduler: () => {} })
applyPage((input, opts) => v4head.push(input, opts))
const v4entry = v4head.push({ title: 'A', meta: [{ name: 'description', content: 'a' }] })
let i4 = 0
bench('v4 (batched, one flush)', () => {
for (let n = 0; n < 50; n++) v4entry.patch({ title: `A${i4++}`, meta: [{ name: 'description', content: `a${i4}` }] })
v4head.render()
})
})
114 changes: 114 additions & 0 deletions bench/v4-client-reclaim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Reclaim semantics for the flat stride-3 effects encoding: the lockstep
* prefix/suffix diff plus seen-set middle must only undo effects that were
* not re-applied this render. These cases exercise the divergent-middle
* paths that the shared parity workload does not.
*/
import { JSDOM } from 'jsdom'
import { describe, expect, it } from 'vitest'
import { createHead } from '../packages/unhead/src/v4/client'

const BLANK = '<!DOCTYPE html><html><head></head><body></body></html>'

function make() {
const dom = new JSDOM(BLANK)
const doc = dom.window.document
const head = createHead({ document: doc, scheduler: () => {} })
return { doc, head }
}

describe('v4 client reclaim', () => {
it('disposing a middle entry removes only its effects', () => {
const { doc, head } = make()
head.push({ meta: [{ name: 'a', content: '1' }], script: [{ src: '/a.js' }] })
const mid = head.push({ meta: [{ name: 'b', content: '2' }], link: [{ rel: 'stylesheet', href: '/x.css' }] })
head.push({ meta: [{ name: 'c', content: '3' }] })
head.render()
expect(doc.head.children.length).toBe(5)
mid.dispose()
head.render()
expect(doc.querySelector('meta[name=b]')).toBeNull()
expect(doc.querySelector('link')).toBeNull()
expect(doc.querySelector('meta[name=a]')?.getAttribute('content')).toBe('1')
expect(doc.querySelector('meta[name=c]')?.getAttribute('content')).toBe('3')
expect(doc.querySelector('script[src="/a.js"]')).toBeTruthy()
})

it('patching a class token undoes only the dropped token', () => {
const { doc, head } = make()
const entry = head.push({ htmlAttrs: { class: 'a b c', lang: 'en' } })
head.render()
expect(doc.documentElement.className).toBe('a b c')
entry.patch({ htmlAttrs: { class: 'a d c', lang: 'en' } })
head.render()
expect([...doc.documentElement.classList].sort()).toEqual(['a', 'c', 'd'])
expect(doc.documentElement.getAttribute('lang')).toBe('en')
})

it('removing a prop from a kept element undoes just that attr', () => {
const { doc, head } = make()
const entry = head.push({ script: [{ 'src': '/a.js', 'defer': true, 'data-x': '1', 'key': 'k' }] })
head.render()
const el = doc.querySelector('script')!
expect(el.hasAttribute('defer')).toBe(true)
entry.patch({ script: [{ 'src': '/a.js', 'data-x': '2', 'key': 'k' }] })
head.render()
expect(doc.querySelector('script')).toBe(el) // same element kept
expect(el.hasAttribute('defer')).toBe(false)
expect(el.getAttribute('data-x')).toBe('2')
expect(el.getAttribute('src')).toBe('/a.js')
})

it('duplicate positional tags survive disposing the first owner', () => {
const { doc, head } = make()
// src-only scripts have no dedupe identity (d === ''), so both render and
// share a hash base; disposing the first shifts the survivor onto key 0
const first = head.push({ script: [{ src: '/dup.js' }] })
head.push({ script: [{ src: '/dup.js' }] })
head.render()
expect(doc.querySelectorAll('script').length).toBe(2)
first.dispose()
head.render()
const scripts = doc.querySelectorAll('script')
expect(scripts.length).toBe(1)
expect(scripts[0].getAttribute('src')).toBe('/dup.js')
})

it('event listeners rebind on handler change and detach on dispose', () => {
const { doc, head } = make()
const calls: string[] = []
const h1 = () => calls.push('h1')
const h2 = () => calls.push('h2')
const entry = head.push({ script: [{ src: '/a.js', onload: h1, key: 'k' }] })
head.render()
const el = doc.querySelector('script')!
el.dispatchEvent(new (doc.defaultView as any).Event('load'))
expect(calls).toEqual(['h1'])
entry.patch({ script: [{ src: '/a.js', onload: h2, key: 'k' }] })
head.render()
el.dispatchEvent(new (doc.defaultView as any).Event('load'))
expect(calls).toEqual(['h1', 'h2'])
entry.dispose()
head.render()
el.dispatchEvent(new (doc.defaultView as any).Event('load'))
expect(calls).toEqual(['h1', 'h2'])
})

it('interleaved dispose keeps re-applied effects between two change points', () => {
const { doc, head } = make()
// low-weight script sorts before the metas, so its effects sit between
// the divergence points when both ends change
const a = head.push({ meta: [{ name: 'aa', content: '1' }] }, { tagPriority: 10 })
head.push({ script: [{ src: '/keep.js', defer: true }] })
const b = head.push({ meta: [{ name: 'zz', content: '9' }] }, { tagPriority: 200 })
head.render()
a.dispose()
b.dispose()
head.render()
expect(doc.querySelector('meta[name=aa]')).toBeNull()
expect(doc.querySelector('meta[name=zz]')).toBeNull()
const keep = doc.querySelector('script[src="/keep.js"]')!
expect(keep).toBeTruthy()
expect(keep.hasAttribute('defer')).toBe(true)
})
})
102 changes: 102 additions & 0 deletions bench/v4-compat.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { V4Head } from '../packages/unhead/src/v4/core'
/**
* Quantifies the L2 compat surface: what the three resolve-slot plugins cost
* per render, and useSeoMeta expansion cost, v3 vs v4.
*/
import { bench, describe } from 'vitest'
import { useSeoMeta as useSeoMetaV3 } from '../packages/unhead/src'
import {
CanonicalPlugin as CanonicalPluginV3,
InferSeoMetaPlugin as InferSeoMetaPluginV3,
TemplateParamsPlugin as TemplateParamsPluginV3,
} from '../packages/unhead/src/plugins'
import { createHead as createV3, renderSSRHead as renderV3 } from '../packages/unhead/src/server'
import {
CanonicalPlugin,
InferSeoMetaPlugin,
TemplateParamsPlugin,
useTemplateParams,
} from '../packages/unhead/src/v4/plugins'
import { useSeoMeta } from '../packages/unhead/src/v4/seo'
import { createHead as createV4, renderSSRHead as renderV4 } from '../packages/unhead/src/v4/server'
import { applyPage } from './v4/fixtures'

const SEO_INPUT = {
title: 'About',
titleTemplate: '%s · Harlan Wilton',
description: 'Open source developer.',
ogTitle: 'About',
ogDescription: 'Open source developer.',
ogType: 'website',
ogUrl: 'https://harlanzw.com/about',
ogSiteName: 'Harlan Wilton',
ogImage: [{ url: 'https://harlanzw.com/og.png', width: 1200, height: 600, alt: 'My Image' }],
twitterCard: 'summary_large_image' as const,
twitterSite: '@harlan_zw',
robots: { index: true, follow: true },
}

function withPlugins(head: V4Head): V4Head {
head.use(TemplateParamsPlugin)
head.use(InferSeoMetaPlugin())
head.use(CanonicalPlugin({ canonicalHost: 'https://harlanzw.com' }))
useTemplateParams(head, { separator: '·', siteName: 'Harlan Wilton' })
return head
}

// plugin overhead on the shared typical page: create + push + render
describe('ssr typical page e2e: plugin overhead', () => {
bench('v4 no plugins', () => {
const head = createV4()
applyPage((input, opts) => head.push(input, opts))
renderV4(head)
})
bench('v4 + templateParams + inferSeoMeta + canonical', () => {
const head = withPlugins(createV4())
applyPage((input, opts) => head.push(input, opts))
renderV4(head)
})
bench('v3 + templateParams + inferSeoMeta + canonical', () => {
const head = createV3({
plugins: [TemplateParamsPluginV3, InferSeoMetaPluginV3(), CanonicalPluginV3({ canonicalHost: 'https://harlanzw.com' })],
})
applyPage((input, opts) => head.push(input, opts))
renderV3(head, { omitLineBreaks: true })
})
})

// render-only: isolates the per-render resolve-slot cost from push/compile
describe('ssr resolve+render only: plugin overhead', () => {
const bare = createV4()
applyPage((input, opts) => bare.push(input, opts))
const loaded = withPlugins(createV4())
applyPage((input, opts) => loaded.push(input, opts))
const v3Loaded = createV3({
plugins: [TemplateParamsPluginV3, InferSeoMetaPluginV3(), CanonicalPluginV3({ canonicalHost: 'https://harlanzw.com' })],
})
applyPage((input, opts) => v3Loaded.push(input, opts))

bench('v4 no plugins', () => {
renderV4(bare)
})
bench('v4 all 3 plugins', () => {
renderV4(loaded)
})
bench('v3 all 3 plugins', () => {
renderV3(v3Loaded, { omitLineBreaks: true })
})
})

// flat meta expansion + render e2e
describe('ssr useSeoMeta e2e', () => {
bench('v3', () => {
const head = createV3()
useSeoMetaV3(head, SEO_INPUT)
renderV3(head, { omitLineBreaks: true })
})
bench('v4', () => {
const head = createV4()
useSeoMeta(head, SEO_INPUT)
renderV4(head)
})
})
Loading
Loading