refactor: replace Jinja templates with static frontend pages
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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);
|
||||
lastDom = nd;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user