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`

hi

` + const serverH1 = server.querySelector('h1') + const serverInput = server.querySelector('input') + + const client = html`

hi

` + 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`

count is 0

` + const client = html`

count is 7

` + 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`
go
` + const client = html`
go
` + 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`` + const client = html`` + 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`

old

` + const client = html`

new

` + hydrate(server, client) + assert.strictEqual(server.textContent, 'new') +}) diff --git a/packages/html/test/server.test.js b/packages/html/test/server.test.js index 8f29295..30027b2 100644 --- a/packages/html/test/server.test.js +++ b/packages/html/test/server.test.js @@ -45,9 +45,14 @@ test('boolean attributes render when truthy and drop when falsy', () => { assert.strictEqual(off.toString(), ''.replace(' ', ' ')) }) -test('event handler functions are stripped', () => { +test('event handler functions leave no trace in markup', () => { const res = html`` - assert.strictEqual(res.toString(), '') + assert.strictEqual(res.toString(), '') +}) + +test('handler-shaped attributes with non-function values still render', () => { + const res = html`x` + assert.strictEqual(res.toString(), 'x') }) test('spread-style object interpolation renders attributes', () => {