// 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 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)' }