diff --git a/packages/core/index.js b/packages/core/index.js
index 2638631..a850e05 100644
--- a/packages/core/index.js
+++ b/packages/core/index.js
@@ -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()
diff --git a/packages/core/test/hydration-integration.test.js b/packages/core/test/hydration-integration.test.js
new file mode 100644
index 0000000..91df5c0
--- /dev/null
+++ b/packages/core/test/hydration-integration.test.js
@@ -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`
+
+
count is ${state.count}
+
+
+ `)
+ 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'
+ )
+})
diff --git a/packages/html/hydrate.js b/packages/html/hydrate.js
new file mode 100644
index 0000000..5c433e2
--- /dev/null
+++ b/packages/html/hydrate.js
@@ -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)'
+}
diff --git a/packages/html/package.json b/packages/html/package.json
index 695ffd5..7a29923 100644
--- a/packages/html/package.json
+++ b/packages/html/package.json
@@ -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"
],
diff --git a/packages/html/server.js b/packages/html/server.js
index 309b084..50807ae 100644
--- a/packages/html/server.js
+++ b/packages/html/server.js
@@ -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) {
diff --git a/packages/html/test/hydrate.test.js b/packages/html/test/hydrate.test.js
new file mode 100644
index 0000000..c6c80e0
--- /dev/null
+++ b/packages/html/test/hydrate.test.js
@@ -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`
hi
body
`
+ const b = html`
hi
body
`
+ 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`