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
+119
View File
@@ -0,0 +1,119 @@
// Hydration: adopt server-rendered DOM instead of replacing it.
//
// Because the server and browser renderers serialize identically, morphing
// the first client render onto the server DOM leaves matching nodes
// untouched — the server DOM is adopted in place (same element references,
// form state preserved) and only real differences mutate. What hydrate
// adds over a bare morph is detection: before morphing it walks both trees
// and reports the first place server and client markup disagree, which is
// exactly the class of bug (Date.now() in views, user-specific content,
// stale caches) that otherwise surfaces as a silent flash of changed
// content. The client render always wins.
import morph from './morph.js'
export default function hydrate (oldNode, newNode, opts) {
// The client tree can hold adjacent text nodes ("count is " + "0")
// where the parsed server HTML has one merged run ("count is 0") —
// identical serialization, different granularity. Fold the client tree
// to parser granularity so comparison and morph see matching shapes.
if (newNode.normalize) newNode.normalize()
const onMismatch = opts && opts.onMismatch
if (onMismatch) {
const diff = firstDifference(oldNode, newNode, [])
if (diff) onMismatch(diff)
}
return morph(oldNode, newNode)
}
export { hydrate }
// Depth-first parallel walk; returns { path, reason, server, client } for
// the first disagreement, or null when the trees agree.
function firstDifference (a, b, path) {
if (a.nodeType !== b.nodeType || a.nodeName !== b.nodeName) {
return {
path: pathString(path),
reason: 'node',
server: describe(a),
client: describe(b)
}
}
if (a.nodeType === 3 || a.nodeType === 8) { // text, comment
if (a.nodeValue !== b.nodeValue) {
return {
path: pathString(path),
reason: 'text',
server: a.nodeValue,
client: b.nodeValue
}
}
return null
}
if (a.nodeType === 1) {
const attrDiff = attrDifference(a, b)
if (attrDiff) {
return {
path: pathString(path.concat(a.nodeName.toLowerCase())),
reason: 'attribute',
server: attrDiff.server,
client: attrDiff.client
}
}
const aKids = a.childNodes
const bKids = b.childNodes
if (aKids.length !== bKids.length) {
return {
path: pathString(path.concat(a.nodeName.toLowerCase())),
reason: 'children',
server: aKids.length + ' child node(s)',
client: bKids.length + ' child node(s)'
}
}
for (let i = 0; i < aKids.length; i++) {
const diff = firstDifference(aKids[i], bKids[i], path.concat(a.nodeName.toLowerCase()))
if (diff) return diff
}
}
return null
}
function attrDifference (a, b) {
const aAttrs = a.attributes
const bAttrs = b.attributes
for (let i = 0; i < bAttrs.length; i++) {
const name = bAttrs[i].name
if (a.getAttribute(name) !== bAttrs[i].value) {
return {
server: name + '=' + JSON.stringify(a.getAttribute(name)),
client: name + '=' + JSON.stringify(bAttrs[i].value)
}
}
}
for (let i = 0; i < aAttrs.length; i++) {
const name = aAttrs[i].name
if (!b.hasAttribute(name)) {
return {
server: name + '=' + JSON.stringify(aAttrs[i].value),
client: name + ' (absent)'
}
}
}
return null
}
function describe (node) {
if (!node) return '(missing)'
if (node.nodeType === 3) return 'text ' + JSON.stringify(node.nodeValue)
if (node.nodeType === 8) return 'comment'
return '<' + node.nodeName.toLowerCase() + '>'
}
function pathString (path) {
return path.length ? path.join(' > ') : '(root)'
}
+2
View File
@@ -10,12 +10,14 @@
},
"./browser": "./browser.js",
"./server": "./server.js",
"./hydrate": "./hydrate.js",
"./raw": "./raw.js",
"./morph": "./morph.js"
},
"files": [
"browser.js",
"server.js",
"hydrate.js",
"raw.js",
"morph.js"
],
+13
View File
@@ -11,6 +11,7 @@ const BOOL_PROPS = [
]
const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$', 'i')
const handlerRx = /[^-a-z](on[a-z]+)=$/i
const query = /(?:="|&)[^"]*=$/
export default function html (pieces, ...values) {
@@ -19,6 +20,15 @@ export default function html (pieces, ...values) {
for (let i = 0; i < pieces.length; i++) {
const piece = pieces[i]
if (i < pieces.length - 1) {
// Event handlers are behavior, not markup: `onclick=${fn}` renders
// nothing at all, matching the browser renderer (which sets the
// handler as a property). v7 serialized a useless onclick="".
const handlerMatch = handlerRx.exec(piece)
if (handlerMatch && typeof values[i] === 'function') {
output += piece.slice(0, handlerMatch.index + 1).replace(/\s+$/, ' ')
continue
}
if ((boolMatch = boolPropRx.exec(piece))) {
output += piece.slice(0, boolMatch.index)
if (values[i]) {
@@ -59,6 +69,9 @@ function handleValue (value) {
if (typeof value === 'object') {
if (typeof value.outerHTML === 'string') return value.outerHTML
return Object.keys(value).reduce(function (str, key) {
// handlers in spread objects are behavior too — never serialized
if (typeof value[key] === 'function') return str
if (str.length > 0) str += ' '
if (BOOL_PROPS.indexOf(key) !== -1) {
+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')
})
+7 -2
View File
@@ -45,9 +45,14 @@ test('boolean attributes render when truthy and drop when falsy', () => {
assert.strictEqual(off.toString(), '<input />'.replace(' ', ' '))
})
test('event handler functions are stripped', () => {
test('event handler functions leave no trace in markup', () => {
const res = html`<button onclick=${() => {}}>go</button>`
assert.strictEqual(res.toString(), '<button onclick="">go</button>')
assert.strictEqual(res.toString(), '<button >go</button>')
})
test('handler-shaped attributes with non-function values still render', () => {
const res = html`<a onclick=${'confirm()'}>x</a>`
assert.strictEqual(res.toString(), '<a onclick="confirm()">x</a>')
})
test('spread-style object interpolation renders attributes', () => {