Files
vacuum-wall/webui/static/hoover/render.js
T
2026-06-17 03:41:08 +00:00

230 lines
7.2 KiB
JavaScript

/**
* Hoover — render.js
*
* Render engine: render(container, fn), container-level diffing,
* batched re-render loop integration with reactivity.js.
*/
import { requestUpdate, setCommitFn } from './reactivity.js';
import {
_vnodeDom, createDom, getDom, patchNode,
setMountFn, setUnmountFn,
} from './vdom.js';
import { mountComponent, unmountComponent } from './component.js';
/** Container → previous root vnodes */
export const _renderSlots = new Map();
/** Container → render function */
export const _renderFns = new Map();
/** Component key → last normalized #comp output (for _vnodeDom preservation) */
export const _compExpandedCache = new Map();
/**
* Set up lifecycle callback hooks from vdom.js.
* Called once during render initialization.
*/
setMountFn((el) => {
// Reserved for future DOM-level mount hooks
});
setUnmountFn((el) => {
// Called during sweepDom for cleanup
});
/**
* Commit callback: re-renders all registered containers in batch.
* Set as the callback for reactivity.js's requestUpdate().
*/
function commitAll() {
for (const container of _renderFns.keys()) {
commit(container);
}
}
setCommitFn(commitAll);
/**
* Mount a render function onto a DOM container.
* - First call: create DOM from scratch, append to container
* - Subsequent calls: diff against previous VNodes, patch in place
*/
export function render(container, fn) {
_renderFns.set(container, fn);
commit(container);
}
/**
* Evaluate render function, diff vs previous, commit to _renderSlots.
*/
function commit(container) {
const fn = _renderFns.get(container);
if (!fn) return;
let result = fn();
if (typeof result === 'function') result = result();
const prev = _renderSlots.get(container);
// Normalize: expand #comp vnodes and track lifecycle
const vnodes = normalizeVNodesWithLifecycle(result, prev);
if (!prev) {
for (const v of vnodes) {
const d = createDom(v);
_vnodeDom.set(v, d);
container.appendChild(d);
}
} else {
diffContainer(container, prev, vnodes);
}
_renderSlots.set(container, vnodes);
}
/**
* Normalize render output: filter nulls, expand #comp vnodes,
* and manage component lifecycle based on key changes.
*/
function normalizeVNodesWithLifecycle(result, prevVnodes) {
const oldEntries = prevVnodes ? collectCompEntries(prevVnodes, []) : [];
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
const newEntries = [];
const normalized = normalizeRecursive(result, oldKeyMap, newEntries);
for (const entry of oldEntries) {
if (!newEntries.some(e => e.key === entry.key)) {
unmountComponent(entry.key, entry.renderer);
}
}
for (const entry of newEntries) {
if (!oldKeyMap.has(entry.key)) {
mountComponent(entry.key, entry.renderer);
}
}
return normalized;
}
/**
* Recursively normalize a value to a flat VNode array, expanding
* #comp vnodes into their rendered content while tracking lifecycle.
*
* When prevCh is provided, preserves _vnodeDom entries so that diff
* can locate existing DOM after normalization creates new vnode objects.
*/
function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
if (result == null) return [];
if (Array.isArray(result)) {
const flat = [];
let idx = 0;
for (const item of result) {
flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx]));
idx++;
}
return flat;
}
const vnode = result;
if (typeof vnode !== 'object') return [{ tag: '#text', text: String(vnode) }];
if (vnode.tag === '#text') return [vnode];
if (vnode.tag === '#comp') {
const renderer = vnode.props?.component;
const key = vnode.props?.key;
if (key !== undefined) {
const existing = newEntries.find(e => e.key === key);
if (!existing) newEntries.push({ key, renderer });
}
if (renderer && typeof renderer === 'function') {
const content = renderer();
const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null;
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded);
if (key !== undefined) _compExpandedCache.set(key, result);
return result;
}
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh);
}
const rawChildren = vnode.ch || [];
const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null;
const children = [];
for (let i = 0; i < rawChildren.length; i++) {
const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i]);
children.push(...normalized);
}
const newVNode = { tag: vnode.tag, props: vnode.props, ch: children };
// Preserve _vnodeDom entry: if the old vnode at this position had a
// DOM association, transfer it to the new normalized vnode so diff
// can locate existing DOM without creating duplicates.
if (prevCh && _vnodeDom.has(prevCh)) {
_vnodeDom.set(newVNode, _vnodeDom.get(prevCh));
}
return [newVNode];
}
/** Collect all #comp entries {key, renderer} from a vnode tree. */
function collectCompEntries(vnodes, entries) {
for (const v of vnodes || []) {
if (!v) continue;
if (v.tag === '#comp') {
const key = v.props?.key;
const renderer = v.props?.component;
if (key !== undefined) entries.push({ key, renderer });
}
if (v.ch) collectCompEntries(v.ch, entries);
}
return entries;
}
/**
* Diff two VNode arrays inside a container, patching in place.
*
* Fix: anchor tracking ensures correct DOM insertion order.
* Fix: _vnodeDom updated after every patch.
*/
function diffContainer(container, prev, vnodes) {
const maxLen = Math.max(vnodes.length, prev.length);
let lastDom = null;
for (let i = 0; i < maxLen; i++) {
const oldV = prev[i], newV = vnodes[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);
container.insertBefore(d, lastDom ? lastDom.nextSibling : null);
lastDom = d;
continue;
}
const oldDom = getDom(oldV);
if (oldDom && oldV.tag === newV.tag) {
patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null);
lastDom = getDom(newV);
} else {
if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
if (oldDom?.parentNode) {
oldDom.parentNode.replaceChild(nd, oldDom);
} else if (nd.parentNode !== container) {
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
}
lastDom = nd;
}
}
}