Three end-to-end scenarios against a static+SSR test server: the zero-build page (import map + native ESM, no bundler), the SSR page (rendered content in the raw response, live after hydration, no mismatch warnings), and adoption (an expando on the server-rendered node survives a real render). Renders are raf-batched so assertions poll. Real Chromium flushed out three fixes happy-dom couldn't see: - nanoraf called an extracted requestAnimationFrame bare — Illegal invocation under strict-mode ESM (sloppy CJS had masked it); wrapped. - the counter example only routed '/', so serving it from any subpath threw; it now has a wildcard fallback. - hydration mismatch detection is now whitespace-insensitive (the parser reparents whitespace, e.g. text after </body>), and page scripts belong in <head> when a view owns <body> — same convention bankai v9 used. CI gets an e2e job with chromium-headless-shell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
135 lines
4.2 KiB
JavaScript
135 lines
4.2 KiB
JavaScript
// 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
|
|
// whitespace-insensitive: the parser shuffles insignificant
|
|
// whitespace (e.g. text after </body> reparents into body), and
|
|
// morph reconciles it silently — only report meaningful text
|
|
if (a.nodeValue.trim() !== b.nodeValue.trim()) {
|
|
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 = significant(a.childNodes)
|
|
const bKids = significant(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
|
|
}
|
|
|
|
// child nodes that matter for comparison: everything except
|
|
// whitespace-only text nodes
|
|
function significant (childNodes) {
|
|
const out = []
|
|
for (let i = 0; i < childNodes.length; i++) {
|
|
const node = childNodes[i]
|
|
if (node.nodeType === 3 && !node.nodeValue.trim()) continue
|
|
out.push(node)
|
|
}
|
|
return out
|
|
}
|
|
|
|
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)'
|
|
}
|