ui: per-container #comp lifecycle, exp-claim auth refresh TTL

- hoover: #comp registry + expanded-content cache now per render
  container; committing one root no longer unmounts/remounts
  components owned by another root (infinite load loop on pages
  whose load() re-mutates reactive state)
- auth_model: refresh timer scheduled from the token's remaining
  exp claim (unverified decode, mirrors lib/auth.py); falls back to
  the configured TTL for non-JWT/malformed/already-expired tokens
- docs: hoover.md documents both behaviors
- tests: exp-claim TTL cases in test-auth-model.js; new
  test-render-lifecycle.js regression suite
This commit is contained in:
2026-09-03 17:25:22 +00:00
parent fc478a016e
commit 2b7fe1f485
6 changed files with 466 additions and 33 deletions
+46 -22
View File
@@ -18,11 +18,31 @@ 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();
/** Container → (component key → last normalized #comp output, for _vnodeDom preservation) */
const _compExpandedCaches = new Map();
/** Component key → renderer function (survives normalization that expands #comp) */
const _compRegistry = new Map();
/** Container → (component key → renderer function). Per-container: a commit of one
* render root must not unmount/prune components owned by another root (e.g. #main's
* page when #sidebar commits). Survives normalization that expands #comp. */
const _compRegistries = new Map();
function _registryFor(container) {
let m = _compRegistries.get(container);
if (!m) {
m = new Map();
_compRegistries.set(container, m);
}
return m;
}
function _expandedCacheFor(container) {
let m = _compExpandedCaches.get(container);
if (!m) {
m = new Map();
_compExpandedCaches.set(container, m);
}
return m;
}
/**
* Set up lifecycle callback hooks from vdom.js.
@@ -69,8 +89,9 @@ function commit(container) {
if (typeof result === 'function') result = result();
const prev = _renderSlots.get(container);
// Normalize: expand #comp vnodes and track lifecycle
const vnodes = normalizeVNodesWithLifecycle(result, prev);
// Normalize: expand #comp vnodes and track lifecycle (this container's own
// registry — other roots' commits must not touch our component keys).
const vnodes = normalizeVNodesWithLifecycle(result, prev, container);
if (!prev) {
for (const v of vnodes) {
@@ -89,16 +110,18 @@ function commit(container) {
* 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 }));
function normalizeVNodesWithLifecycle(result, prevVnodes, container) {
const registry = _registryFor(container);
const compCache = _expandedCacheFor(container);
const oldEntries = [...registry.entries()].map(([key, renderer]) => ({ key, renderer }));
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
const newEntries = [];
const normalized = normalizeRecursive(result, oldKeyMap, newEntries);
const normalized = normalizeRecursive(result, oldKeyMap, newEntries, null, compCache);
for (const entry of oldEntries) {
if (!newEntries.some(e => e.key === entry.key)) {
unmountComponent(entry.key, entry.renderer);
unmountComponent(entry.key, entry.renderer, compCache);
}
}
for (const entry of newEntries) {
@@ -107,14 +130,15 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
}
}
// Sync registry with current render (prevVnodes are normalized and lack #comp tags,
// so collectCompEntries always returns [] after the first render)
// Sync this container's registry with the 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 [key] of registry) {
if (!newKeySet.has(key)) registry.delete(key);
}
for (const entry of newEntries) {
_compRegistry.set(entry.key, entry.renderer);
registry.set(entry.key, entry.renderer);
}
return normalized;
@@ -127,13 +151,13 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
* 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) {
function normalizeRecursive(result, oldKeyMap, newEntries, prevCh, compCache) {
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]));
flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx], compCache));
idx++;
}
return flat;
@@ -152,19 +176,19 @@ function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
}
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);
const prevExpanded = key !== undefined && compCache ? compCache.get(key) : null;
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded, compCache);
if (key !== undefined && compCache) compCache.set(key, result);
return result;
}
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh);
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh, compCache);
}
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]);
const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i], compCache);
children.push(...normalized);
}