feat(html): adoption-style hydration with mismatch reporting

hydrate(oldNode, newNode) normalizes the client tree to parser text-node
granularity, walks both trees to report the first server/client markup
disagreement (node, text, attribute, or child count, with a path), then
morphs — matching nodes are adopted in place, the client render wins.
choo.mount() now hydrates and console.warns on mismatch.

Building this surfaced two real isomorphism divergences, both fixed:
adjacent text nodes from template holes vs the parser's merged runs
(folded via Node.normalize), and the server serializing onclick="" where
the browser sets a property — event handlers now leave no trace in server
markup at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-08 17:49:32 +02:00
co-authored by Claude Fable 5
parent 37bd8adf81
commit 13155a3469
7 changed files with 323 additions and 3 deletions
+75
View File
@@ -0,0 +1,75 @@
// Hydration: adoption semantics and mismatch detection.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let html, hydrate
before(async () => {
const win = new Window()
globalThis.window = win
globalThis.document = win.document
;({ default: html } = await import('../browser.js'))
;({ default: hydrate } = await import('../hydrate.js'))
})
test('agreeing trees hydrate without a mismatch report', () => {
const a = html`<div class="x"><h1>hi</h1><p>body</p></div>`
const b = html`<div class="x"><h1>hi</h1><p>body</p></div>`
let reported = null
hydrate(a, b, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported, null)
})
test('adoption: matching child nodes keep their identity through hydrate', () => {
const server = html`<div><h1>hi</h1><input value="typed by user" /></div>`
const serverH1 = server.querySelector('h1')
const serverInput = server.querySelector('input')
const client = html`<div><h1>hi</h1><input value="typed by user" /></div>`
hydrate(server, client)
assert.strictEqual(server.querySelector('h1'), serverH1, 'h1 adopted in place')
assert.strictEqual(server.querySelector('input'), serverInput, 'input adopted in place')
})
test('text mismatches are reported with a path, client wins', () => {
const server = html`<div><h1>count is 0</h1></div>`
const client = html`<div><h1>count is 7</h1></div>`
let reported = null
hydrate(server, client, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported.reason, 'text')
assert.match(reported.path, /div > h1/)
assert.strictEqual(reported.server, 'count is 0')
assert.strictEqual(reported.client, 'count is 7')
assert.strictEqual(server.textContent, 'count is 7', 'client render won')
})
test('attribute mismatches are reported', () => {
const server = html`<div><a href="/old">go</a></div>`
const client = html`<div><a href="/new">go</a></div>`
let reported = null
hydrate(server, client, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported.reason, 'attribute')
assert.match(reported.server, /\/old/)
assert.match(reported.client, /\/new/)
})
test('child count mismatches are reported', () => {
const server = html`<ul><li>a</li></ul>`
const client = html`<ul><li>a</li><li>b</li></ul>`
let reported = null
hydrate(server, client, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported.reason, 'children')
})
test('mismatch detection is optional and hydrate still morphs without it', () => {
const server = html`<p>old</p>`
const client = html`<p>new</p>`
hydrate(server, client)
assert.strictEqual(server.textContent, 'new')
})