3de82e3b9b
Drop ?v=N version pins from all JS imports and HTML <link>/<script> tags. Cache invalidation is now handled solely by server-side cache-control headers. Update docs and AGENTS.md accordingly.
254 lines
8.3 KiB
JavaScript
254 lines
8.3 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, sweepDom,
|
|
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();
|
|
|
|
/** Component key → renderer function (survives normalization that expands #comp) */
|
|
const _compRegistry = 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 = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer }));
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Sync registry with current render (prevVnodes are normalized and lack #comp tags,
|
|
// so collectCompEntries always returns [] after the first render)
|
|
const newKeySet = new Set(newEntries.map(e => e.key));
|
|
for (const [key] of _compRegistry) {
|
|
if (!newKeySet.has(key)) _compRegistry.delete(key);
|
|
}
|
|
for (const entry of newEntries) {
|
|
_compRegistry.set(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);
|
|
lastDom = i > 0 ? getDom(prev[i - 1]) : null;
|
|
}
|
|
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 && !oldDom.parentNode) {
|
|
// oldDom exists in _vnodeDom but detached from the tree
|
|
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
|
const nd = createDom(newV);
|
|
_vnodeDom.set(newV, nd);
|
|
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
|
|
lastDom = nd;
|
|
} else if (oldDom && oldV.tag !== newV.tag) {
|
|
// tag mismatch — replace old DOM with new
|
|
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
|
const nd = createDom(newV);
|
|
_vnodeDom.set(newV, nd);
|
|
oldDom.parentNode.replaceChild(nd, oldDom);
|
|
lastDom = nd;
|
|
} else {
|
|
// oldDom is null — create and insert new DOM
|
|
const nd = createDom(newV);
|
|
_vnodeDom.set(newV, nd);
|
|
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
|
|
lastDom = nd;
|
|
}
|
|
}
|
|
}
|