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:
co-authored by
Claude Fable 5
parent
37bd8adf81
commit
13155a3469
+14
-1
@@ -2,6 +2,7 @@
|
|||||||
// Same API, same event flow; ESM, consolidated nano* internals in ./lib.
|
// Same API, same event flow; ESM, consolidated nano* internals in ./lib.
|
||||||
|
|
||||||
import morph from '@choojs/html/morph'
|
import morph from '@choojs/html/morph'
|
||||||
|
import hydrate from '@choojs/html/hydrate'
|
||||||
import nanotiming from './lib/timing.js'
|
import nanotiming from './lib/timing.js'
|
||||||
import Nanorouter from './lib/router.js'
|
import Nanorouter from './lib/router.js'
|
||||||
import Nanobus from './lib/bus.js'
|
import Nanobus from './lib/bus.js'
|
||||||
@@ -213,8 +214,20 @@ export class Choo {
|
|||||||
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
|
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
|
||||||
newTree.nodeName.toLowerCase() + '>.')
|
newTree.nodeName.toLowerCase() + '>.')
|
||||||
|
|
||||||
|
// First render adopts the existing (usually server-rendered) DOM:
|
||||||
|
// matching nodes are left in place, and any server/client markup
|
||||||
|
// disagreement is reported before the client render wins.
|
||||||
const morphTiming = nanotiming('choo.morph')
|
const morphTiming = nanotiming('choo.morph')
|
||||||
morph(self._tree, newTree)
|
hydrate(self._tree, newTree, {
|
||||||
|
onMismatch: function (diff) {
|
||||||
|
console.warn(
|
||||||
|
'choo.mount: server and client markup differ at ' + diff.path +
|
||||||
|
' (' + diff.reason + '): server rendered ' + diff.server +
|
||||||
|
', client rendered ' + diff.client +
|
||||||
|
'. The client version wins; fix the view so both sides agree.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
morphTiming()
|
morphTiming()
|
||||||
|
|
||||||
renderTiming()
|
renderTiming()
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// The isomorphic handshake: server-render a view to a string, parse it
|
||||||
|
// into the document the way a browser would, then mount() the same app —
|
||||||
|
// the server DOM must be adopted (same element references), handlers must
|
||||||
|
// come alive, and a tampered server render must produce a console warning.
|
||||||
|
|
||||||
|
import { test, before } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { Window } from 'happy-dom'
|
||||||
|
|
||||||
|
let choo, browserHtml, serverString
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Server side first, before any window exists — exactly like production,
|
||||||
|
// and toString() enforces it.
|
||||||
|
;({ default: choo } = await import('@choojs/core'))
|
||||||
|
const { default: serverHtml } = await import('@choojs/html/server')
|
||||||
|
serverString = makeApp(serverHtml).toString('/')
|
||||||
|
|
||||||
|
const win = new Window({ url: 'http://localhost/' })
|
||||||
|
globalThis.window = win
|
||||||
|
globalThis.document = win.document
|
||||||
|
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
|
||||||
|
;({ default: browserHtml } = await import('@choojs/html/browser'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
// One view definition, parameterized by renderer — mirrors how the import
|
||||||
|
// map serves browser.js to the browser and server.js to Node.
|
||||||
|
function makeApp (html) {
|
||||||
|
const app = choo()
|
||||||
|
app.use((state, emitter) => {
|
||||||
|
state.count = state.count || 0
|
||||||
|
emitter.on('increment', (n) => {
|
||||||
|
state.count += n
|
||||||
|
emitter.emit('render')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
app.route('/', (state, emit) => html`
|
||||||
|
<div class="app">
|
||||||
|
<h1>count is ${state.count}</h1>
|
||||||
|
<button onclick=${() => emit('increment', 1)}>Increment</button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mount adopts server-rendered DOM and brings it alive', async () => {
|
||||||
|
document.body.innerHTML = serverString
|
||||||
|
const serverEl = document.querySelector('.app')
|
||||||
|
const serverH1 = serverEl.querySelector('h1')
|
||||||
|
|
||||||
|
const warnings = []
|
||||||
|
const warn = console.warn
|
||||||
|
console.warn = (msg) => warnings.push(msg)
|
||||||
|
|
||||||
|
const app = makeApp(browserHtml)
|
||||||
|
app.mount('.app')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
console.warn = warn
|
||||||
|
|
||||||
|
assert.strictEqual(document.querySelector('.app'), serverEl, 'server root adopted, not replaced')
|
||||||
|
assert.strictEqual(serverEl.querySelector('h1'), serverH1, 'server child adopted, not replaced')
|
||||||
|
assert.deepStrictEqual(warnings, [], 'identical markup produced no mismatch warnings')
|
||||||
|
|
||||||
|
serverEl.querySelector('button').click()
|
||||||
|
await tick()
|
||||||
|
assert.strictEqual(serverH1.textContent, 'count is 1', 'adopted DOM is live')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a tampered server render produces one mismatch warning, client wins', async () => {
|
||||||
|
document.body.innerHTML = serverString.replace('count is 0', 'count is 999')
|
||||||
|
|
||||||
|
const warnings = []
|
||||||
|
const warn = console.warn
|
||||||
|
console.warn = (msg) => warnings.push(msg)
|
||||||
|
|
||||||
|
const app = makeApp(browserHtml)
|
||||||
|
app.mount('.app')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
console.warn = warn
|
||||||
|
|
||||||
|
assert.strictEqual(warnings.length, 1, 'exactly one warning')
|
||||||
|
assert.match(warnings[0], /server and client markup differ/)
|
||||||
|
assert.match(warnings[0], /count is 999/)
|
||||||
|
assert.strictEqual(
|
||||||
|
document.querySelector('h1').textContent,
|
||||||
|
'count is 0',
|
||||||
|
'client render won'
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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)'
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@
|
|||||||
},
|
},
|
||||||
"./browser": "./browser.js",
|
"./browser": "./browser.js",
|
||||||
"./server": "./server.js",
|
"./server": "./server.js",
|
||||||
|
"./hydrate": "./hydrate.js",
|
||||||
"./raw": "./raw.js",
|
"./raw": "./raw.js",
|
||||||
"./morph": "./morph.js"
|
"./morph": "./morph.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"browser.js",
|
"browser.js",
|
||||||
"server.js",
|
"server.js",
|
||||||
|
"hydrate.js",
|
||||||
"raw.js",
|
"raw.js",
|
||||||
"morph.js"
|
"morph.js"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const BOOL_PROPS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$', 'i')
|
const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$', 'i')
|
||||||
|
const handlerRx = /[^-a-z](on[a-z]+)=$/i
|
||||||
const query = /(?:="|&)[^"]*=$/
|
const query = /(?:="|&)[^"]*=$/
|
||||||
|
|
||||||
export default function html (pieces, ...values) {
|
export default function html (pieces, ...values) {
|
||||||
@@ -19,6 +20,15 @@ export default function html (pieces, ...values) {
|
|||||||
for (let i = 0; i < pieces.length; i++) {
|
for (let i = 0; i < pieces.length; i++) {
|
||||||
const piece = pieces[i]
|
const piece = pieces[i]
|
||||||
if (i < pieces.length - 1) {
|
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))) {
|
if ((boolMatch = boolPropRx.exec(piece))) {
|
||||||
output += piece.slice(0, boolMatch.index)
|
output += piece.slice(0, boolMatch.index)
|
||||||
if (values[i]) {
|
if (values[i]) {
|
||||||
@@ -59,6 +69,9 @@ function handleValue (value) {
|
|||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
if (typeof value.outerHTML === 'string') return value.outerHTML
|
if (typeof value.outerHTML === 'string') return value.outerHTML
|
||||||
return Object.keys(value).reduce(function (str, key) {
|
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 (str.length > 0) str += ' '
|
||||||
|
|
||||||
if (BOOL_PROPS.indexOf(key) !== -1) {
|
if (BOOL_PROPS.indexOf(key) !== -1) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -45,9 +45,14 @@ test('boolean attributes render when truthy and drop when falsy', () => {
|
|||||||
assert.strictEqual(off.toString(), '<input />'.replace(' ', ' '))
|
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>`
|
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', () => {
|
test('spread-style object interpolation renders attributes', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user