/** * Hoover — vdom.js * * Virtual DOM: h() factory, vnode creation, diffing, patching. * Maintains _vnodeDom WeakMap for vnode ↔ DOM element resolution. * * Critical fixes vs. reactive-dom.js: * - _vnodeDom updated after EVERY vnode→dom assignment * - Keyed diff with proper element reordering * - Unkeyed diff with anchor tracking * - Proper unmountTree for cleanup (fires registered onUnmount hooks) */ // Exported so render.js can access it export const _vnodeDom = new WeakMap(); // Lifecycle hooks registry (component.js populates this) export const _mountFn = { fn: null }; export const _unmountFn = { fn: null }; export function setMountFn(fn) { _mountFn.fn = fn; } export function setUnmountFn(fn) { _unmountFn.fn = fn; } /** * Build a VNode. Three forms: * h('div', { class: 'x' }, h('span', null, 'hi')) — element * h(ComponentFn, { prop: 1 }, child1, child2) — component (fn called) * h('#text', 'some text') — text node */ export function h(tag, props, ...children) { if (typeof tag === 'function') { const base = typeof props === 'object' && props !== null ? props : {}; if (!base.children && children.length) base.children = flatten(children); return tag(base); } if (tag === '#text') return { tag: '#text', text: String(props) }; if (tag === '#comp') { return { tag: '#comp', props: props || {}, ch: flatten(children) }; } return { tag, props: props || {}, ch: flatten(children) }; } /** Flatten nested arrays / primitives → VNode array. */ function flatten(arr) { const out = []; for (const c of arr.flat(Infinity)) { if (c == null || typeof c === 'boolean') continue; out.push( typeof c === 'string' || typeof c === 'number' ? { tag: '#text', text: String(c) } : c, ); } return out; } /** * Look up the real DOM element for a VNode via _vnodeDom. */ export function getDom(vnode) { return vnode ? _vnodeDom.get(vnode) : null; } /** * Create a real DOM element (or subtree) from a VNode. * Also registers _vnodeDom mapping for the created element and all descendants. */ export function createDom(vnode) { if (!vnode) return document.createTextNode(''); if (vnode.tag === '#text') { const tn = document.createTextNode(vnode.text || ''); _vnodeDom.set(vnode, tn); return tn; } const el = document.createElement(vnode.tag); applyProps(el, vnode.props); _vnodeDom.set(vnode, el); for (const c of vnode.ch || []) { el.appendChild(createDom(c)); } return el; } /** Apply every prop on an element (initial mount). */ export function applyProps(el, props) { for (const [k, v] of Object.entries(props)) setProp(el, k, v); } /** Set a single prop (or event) on an element. */ export function setProp(el, key, value) { if (key === 'key' || key === 'ref') return; if (key === 'html') { el.innerHTML = String(value); return; } if (key === 'innerHTML') { el.innerHTML = String(value); return; } if (key === 'textContent') { el.textContent = String(value); return; } if (key.startsWith('on:')) { const ev = key.slice(3); const map = el._evMap || {}; if (map[ev]) el.removeEventListener(ev, map[ev]); if (typeof value === 'function') { el.addEventListener(ev, value); map[ev] = value; } else delete map[ev]; el._evMap = map; return; } if (key === 'class' && typeof value === 'object' && value !== null) { el.className = Object.keys(value).filter(k => value[k]).join(' '); return; } if (key === 'style' && typeof value === 'object' && value !== null) { for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv; return; } const tag = el.tagName.toLowerCase(); if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) { el.value = value == null ? '' : String(value); return; } if (key === 'checked' && tag === 'input') { el.checked = !!value; return; } if (key === 'disabled') { el.disabled = !!value; return; } if (key === 'selected' && tag === 'option') { el.selected = !!value; return; } if (value == null || value === false || value === undefined) el.removeAttribute(key); else el.setAttribute(key, value === true ? '' : String(value)); } /** Remove a single prop from an element. */ export function unsetProp(el, key) { if (key === 'key' || key === 'ref') return; if (key.startsWith('on:')) { const ev = key.slice(3); const map = el._evMap || {}; if (map[ev]) { el.removeEventListener(ev, map[ev]); delete map[ev]; } el._evMap = map; return; } const tag = el.tagName.toLowerCase(); if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) return; if (key === 'checked' && tag === 'input') { el.checked = false; return; } if (key === 'disabled') { el.disabled = false; return; } if (key === 'selected' && tag === 'option') { el.selected = false; return; } el.removeAttribute(key); } /** Diff two props objects and patch the element in place. */ export function patchProps(el, oldP = {}, newP = {}) { for (const k of new Set([...Object.keys(oldP), ...Object.keys(newP)])) { const hasOld = k in oldP, hasNew = k in newP; if (hasOld && hasNew && Object.is(oldP[k], newP[k])) continue; if (hasNew) setProp(el, k, newP[k]); else unsetProp(el, k); } } /** Recursively clean up event listeners and child nodes. */ export function sweepDom(el) { if (_unmountFn.fn) _unmountFn.fn(el); for (const ev of Object.keys(el._evMap || {})) el.removeEventListener(ev, el._evMap[ev]); while (el.firstChild) { const child = el.firstChild; if (child.nodeType === Node.ELEMENT_NODE) sweepDom(child); el.removeChild(child); } } /** * Patch children of a parent element. * Dispatches to keyed or unkeyed patching based on whether any vnode has a key. */ export function patchChildren(parent, oldCh, newCh) { const hasKeys = (ch) => ch.some(v => v?.props?.key != null); if (hasKeys(newCh) && hasKeys(oldCh)) patchKeyed(parent, oldCh, newCh); else patchUnkeyed(parent, oldCh, newCh); } /** * Unkeyed (index-based) children diff. * * Fix: _vnodeDom updated after EVERY vnode→dom assignment. */ export function patchUnkeyed(parent, oldCh, newCh) { const maxLen = Math.max(oldCh.length, newCh.length); let lastDom = null; for (let i = 0; i < maxLen; i++) { const oldV = oldCh[i], newV = newCh[i]; if (!newV && oldV) { const d = getDom(oldV); if (d?.parentNode) { if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); d.parentNode.removeChild(d); } continue; } if (newV && !oldV) { const d = createDom(newV); _vnodeDom.set(newV, d); parent.insertBefore(d, lastDom ? lastDom.nextSibling : null); lastDom = d; continue; } patchNode(parent, oldV, newV, null); lastDom = getDom(newV); } } /** * Keyed children diff — preserves order, reuses DOM by key. * * Fix: proper element reordering using lastDom anchor tracking. */ export function patchKeyed(parent, oldCh, newCh) { const oldMap = new Map( oldCh.filter(v => v?.props?.key != null).map(v => [v.props.key, v]) ); const toRemove = new Set(oldMap.keys()); let lastDom = null; for (const newV of newCh) { const key = newV.props?.key; toRemove.delete(key); const oldV = oldMap.get(key); if (oldV) { patchNode(parent, oldV, newV, null); const d = getDom(newV); if (d) { if (lastDom && d !== lastDom.nextSibling) { parent.insertBefore(d, lastDom.nextSibling || null); } lastDom = d; } } else { const d = createDom(newV); _vnodeDom.set(newV, d); parent.insertBefore(d, lastDom ? lastDom.nextSibling : null); lastDom = d; } } for (const key of toRemove) { const oldV = oldMap.get(key); const d = getDom(oldV); if (d?.parentNode) { if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); d.parentNode.removeChild(d); } } } /** * Patch one VNode against another inside parent. * * - no old → create + insert * - no new → sweep + remove * - tag match → patchProps + patchChildren * - tag mismatch → replace * * Fix: _vnodeDom always set to the correct dom after patch. */ export function patchNode(parent, oldV, newV, anchor) { if (!oldV && !newV) return; if (!oldV) { const d = createDom(newV); _vnodeDom.set(newV, d); parent.insertBefore(d, anchor || null); return; } if (!newV) { const d = getDom(oldV); if (d?.parentNode) { if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); d.parentNode.removeChild(d); } return; } const dom = getDom(oldV); if (!dom || !dom.parentNode) { const d = createDom(newV); _vnodeDom.set(newV, d); parent.insertBefore(d, anchor || null); return; } // Tag changed → full replace if (oldV.tag !== newV.tag) { if (dom.nodeType === Node.ELEMENT_NODE) sweepDom(dom); const nd = createDom(newV); _vnodeDom.set(newV, nd); dom.parentNode.replaceChild(nd, dom); return; } // Text node — fast path if (oldV.tag === '#text') { if (oldV.text !== newV.text) dom.nodeValue = newV.text; _vnodeDom.set(newV, dom); return; } // Element: patch in place patchProps(dom, oldV.props || {}, newV.props || {}); patchChildren(dom, oldV.ch || [], newV.ch || []); _vnodeDom.set(newV, dom); }