wcb/src/utils/patch.mjs
Ayo c7d87086a2
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run
feat: smart diffing
2026-07-19 23:32:19 +02:00

165 lines
5.8 KiB
JavaScript

import { createElement, applyProp } from './create-element.mjs'
/**
* Flattens a vnode or a (possibly nested) children array into the flat list of
* nodes `createElement` would have produced — it nests arrays into document
* fragments, which append flat. `null`/`undefined` entries are dropped so a
* conditional branch removes its node instead of rendering "null".
* @param {any} children a vnode, a children array, or nothing
* @returns {any[]} the flat list of vnodes
*/
function flatten(children) {
return children == null
? []
: [children].flat(Infinity).filter((c) => c != null)
}
/**
* Undoes a prop that is present in the old vnode but gone from the new one,
* mirroring how `applyProp` set it.
* @param {Element} el the element to patch
* @param {string} prop the prop name as written in the vnode
* @param {any} oldValue the value the prop had in the old vnode
*/
function removeProp(el, prop, oldValue) {
const domProp = prop.toLowerCase()
if (domProp === 'style' && typeof oldValue === 'object' && !!oldValue) {
Object.keys(oldValue).forEach((rule) => {
if (rule in el.style) el.style[rule] = ''
})
return
}
const key = prop in el ? prop : domProp in el ? domProp : null
if (key === null) {
el.removeAttribute(prop)
} else {
// reset to the DOM's own empty value for that property's type: `false` for
// booleans (disabled), `''` for strings (id, className), `null` otherwise
// (event handlers, object-valued props)
const current = el[key]
el[key] =
typeof current === 'boolean'
? false
: typeof current === 'string'
? ''
: null
}
}
/**
* Patches a `style` object rule by rule. `applyProp` only ever *sets* truthy
* rules, so on a reused element a rule that was dropped from the object — or
* that went falsy, the usual `{ fontStyle: condition && 'italic' }` shape —
* has to be cleared explicitly. A full rebuild used to do this implicitly.
* @param {Element} el the element to patch
* @param {any} oldValue the previous `style` prop value
* @param {any} newValue the new `style` prop value
*/
function patchStyle(el, oldValue, newValue) {
const previous = typeof oldValue === 'object' && !!oldValue ? oldValue : {}
const next = typeof newValue === 'object' && !!newValue ? newValue : {}
Object.keys(previous).forEach((rule) => {
if (!next[rule] && rule in el.style) el.style[rule] = ''
})
applyProp(el, 'style', next)
}
/**
* Patches an element's props in place: applies additions and changes, then
* removes props that are gone. Function props (event handlers) are always
* re-applied because `render()`'s `JSON.stringify` diff can't see them.
* @param {Element} el the element to patch
* @param {object | null | undefined} oldProps props from the previous vnode
* @param {object | null | undefined} newProps props from the new vnode
*/
function patchProps(el, oldProps, newProps) {
const previous = oldProps ?? {}
const next = newProps ?? {}
Object.entries(next).forEach(([prop, value]) => {
const isStyleObject =
prop.toLowerCase() === 'style' &&
(typeof value === 'object' || typeof previous[prop] === 'object')
if (isStyleObject) patchStyle(el, previous[prop], value)
else if (typeof value === 'function' || !Object.is(previous[prop], value))
applyProp(el, prop, value)
})
Object.keys(previous).forEach((prop) => {
if (!(prop in next)) removeProp(el, prop, previous[prop])
})
}
/**
* Puts `node` where `dom` is, or appends it when there is no node to replace.
* @param {Node} parent the parent node being patched
* @param {Node | null | undefined} dom the node being replaced, if any
* @param {Node | null | undefined} node the replacement
*/
function place(parent, dom, node) {
if (!(node instanceof Node)) return
if (dom) parent.replaceChild(node, dom)
else parent.appendChild(node)
}
/**
* Reconciles one DOM node against a new vnode, reusing it whenever it is the
* same kind of node so focus, selection, uncommitted input values, `:hover`
* and running transitions survive the re-render.
* @param {Node} parent the parent node being patched
* @param {Node | null | undefined} dom the existing node at this index, if any
* @param {any} oldVnode the vnode that produced `dom`, if known
* @param {any} newVnode the vnode to render
*/
export function patchNode(parent, dom, oldVnode, newVnode) {
if (newVnode == null) {
if (dom) parent.removeChild(dom)
return
}
// primitive: keep the text node and retarget its data
if (typeof newVnode !== 'object') {
const data = String(newVnode)
if (dom && dom.nodeType === 3) {
if (dom.data !== data) dom.data = data
} else {
place(parent, dom, document.createTextNode(data))
}
return
}
const sameTag =
dom &&
dom.nodeType === 1 &&
dom.tagName.toLowerCase() === String(newVnode.type).toLowerCase()
if (!sameTag) {
place(parent, dom, createElement(newVnode))
return
}
const previous =
typeof oldVnode === 'object' && oldVnode !== null ? oldVnode : {}
patchProps(dom, previous.props, newVnode.props)
patchChildren(dom, previous.children, newVnode.children)
}
/**
* Reconciles a parent's child nodes against a new children list by index
* (non-keyed), trimming any leftover trailing nodes.
* @param {Node} parent the parent node to patch into
* @param {any} oldChildren the previous vnode children (or previous tree)
* @param {any} newChildren the new vnode children (or new tree)
*/
export function patchChildren(parent, oldChildren, newChildren) {
const previous = flatten(oldChildren)
const next = flatten(newChildren)
const nodes = [...parent.childNodes]
next.forEach((vnode, i) => patchNode(parent, nodes[i], previous[i], vnode))
for (let i = next.length; i < nodes.length; i++) parent.removeChild(nodes[i])
}