ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
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?v=9';
|
|
import {
|
|
_vnodeDom, createDom, getDom, patchNode, sweepDom,
|
|
setMountFn, setUnmountFn,
|
|
} from './vdom.js?v=9';
|
|
import { mountComponent, unmountComponent } from './component.js?v=10';
|
|
|
|
/** 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;
|
|
}
|
|
}
|
|
}
|