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]) }