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.
|
||||
|
||||
import morph from '@choojs/html/morph'
|
||||
import hydrate from '@choojs/html/hydrate'
|
||||
import nanotiming from './lib/timing.js'
|
||||
import Nanorouter from './lib/router.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 <' +
|
||||
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')
|
||||
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()
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user