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
+23 -4
View File
@@ -319,8 +319,9 @@ Returns `{ loading, refreshing, error }` derived from the union of all passed mo
`auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted `auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted
to the single source of truth for the token/session lifecycle: token storage (sessionStorage via to the single source of truth for the token/session lifecycle: token storage (sessionStorage via
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (TTL 60s timer), internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL 60s
session validation, login/logout transitions, and WS reconnection coordination. timer, driven by the token's `exp` claim), session validation, login/logout transitions, and WS
reconnection coordination.
Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()` Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on (requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
@@ -338,7 +339,9 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t
``` ```
app bootstrap → modelFetch('auth', { action: 'check' }) app bootstrap → modelFetch('auth', { action: 'check' })
→ 200: stores verified user/permissions + stored tokens → schedules refresh → 200: stores verified user/permissions + stored tokens → schedules the
refresh at the token's REMAINING lifetime (exp claim, not the full issued
TTL) minus 60s
→ 401 with a stored refresh token (stale access token after page → 401 with a stored refresh token (stale access token after page
reload/restore): exactly one refresh attempt, then the same reload/restore): exactly one refresh attempt, then the same
success or terminal path success or terminal path
@@ -346,7 +349,7 @@ app bootstrap → modelFetch('auth', { action: 'check' })
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' }) apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
→ onSuccess stores rotated tokens (new session_id) or clears + redirects → onSuccess stores rotated tokens (new session_id) or clears + redirects
(no auth:login dispatch) (no auth:login dispatch)
timer fires (TTL 60s) → refreshAuth() → same path timer fires (remaining TTL 60s) → refreshAuth() → same path
WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection) WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection)
login → modelFetch('auth', { action: 'login', payload: data }) login → modelFetch('auth', { action: 'login', payload: data })
→ onSuccess stores + schedules + fires auth:login (login action only) → onSuccess stores + schedules + fires auth:login (login action only)
@@ -382,6 +385,15 @@ any terminal no-token result → onSuccess dispatches auth:logout
`name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths `name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths
(timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant (timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant
secondary guard for the timer path. secondary guard for the timer path.
- **Exp-claim TTL** — `data.ttl` is the access token's *remaining* lifetime, decoded
unverified from the JWT `exp` claim (`tokenRemainingTtlMs`, mirroring the server's own
unverified-payload extraction in `lib/auth.py`); the full issued TTL
(`payload.access_ttl` / stored `vw:access_ttl`) is only the fallback when the claim is
undecodable or the token is already expired. This keeps the in-memory refresh timer
correct on page restore: a session resumed mid-life schedules its refresh from the
actual expiry, not from the moment the model was (re)populated. An already-expired
stored token falls back to the stored TTL and is healed by the `check` 401 one-refresh
path or the first `apiFetch` 401.
- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so - **Socket teardown necessity** — the daemon validates the WS token only at handshake, so
without the terminal `auth:logout``disconnect()` path the previous user's socket would without the terminal `auth:logout``disconnect()` path the previous user's socket would
survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket). survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket).
@@ -564,6 +576,13 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads). Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads).
The `#comp` lifecycle registry (and the expanded-content cache) is **per render container**: a
commit of one root (e.g. `#sidebar`) never unmounts or prunes components owned by another root
(e.g. `#main`'s page). Since `commitAll()` commits every root on each reactive update, a shared
global registry would make the sidebar's commit remount the page on every WS tick/toast/model
update — re-running `load()` and, for pages whose `load()` re-mutates reactive state, spinning
an infinite unmount/remount/load loop.
```javascript ```javascript
// Router pattern — key is the path so navigation to a different page unmounts the old one // Router pattern — key is the path so navigation to a different page unmounts the old one
return hComp(page, this.state.path); return hComp(page, this.state.path);
+112
View File
@@ -185,6 +185,118 @@ test('refresh action rotates tokens; new session_id wins, omitted fields fall ba
assertEq(data.user?.username, 'alice', 'user from response'); assertEq(data.user?.username, 'alice', 'user from response');
}); });
/* ── exp-claim TTL tests ───────────────────────────────────── */
/** Base64url-encode a JSON object (JWT segment builder). */
function b64url(obj) {
return btoa(JSON.stringify(obj))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Build a structurally valid (unsigned) JWT whose exp is offsetSeconds from now. */
function makeJwt(offsetSeconds) {
return [
b64url({ alg: 'HS256' }),
b64url({
sub: 'alice',
exp: Math.floor(Date.now() / 1000) + offsetSeconds,
iat: Math.floor(Date.now() / 1000),
type: 'access',
session_id: 'sess-jwt',
}),
b64url({ sig: true }),
].join('.');
}
/** Most recent defined entry in the captured timer queue. */
function lastTimer() {
for (let i = _timers.length - 1; i >= 0; i--) if (_timers[i]) return _timers[i];
return null;
}
test('check 200: ttl is the token\'s remaining lifetime (exp claim), not the stored full TTL', async () => {
const s = setup({
initialStorage: {
'vw:access': makeJwt(600), // expires in 10 min…
'vw:refresh': 'refresh-old',
'vw:session_id': 'sess-jwt',
'vw:access_ttl': '900000', // …but the stored full TTL says 15 min
},
});
s.route('/api/auth/session', 200, {
ok: true,
data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } },
});
await act('check');
const ttl = getModel('auth').data.ttl;
assert(ttl > 590 * 1000 && ttl <= 600 * 1000,
`remaining ttl (~600s), not the stored 900s: got ${ttl}`);
// scheduleRefresh fires at ttl - 60s — the timer must target the real expiry.
const t = lastTimer();
assert(t && t.ms > 530 * 1000 && t.ms <= 540 * 1000,
`refresh timer targets expiry - 60s: got ${t && t.ms}`);
});
test('check 200: already-expired token falls back to the stored TTL (401 recovery path applies)', async () => {
const s = setup({
initialStorage: {
'vw:access': makeJwt(-10), // already expired
'vw:refresh': 'refresh-old',
'vw:session_id': 'sess-jwt',
'vw:access_ttl': '900000',
},
});
s.route('/api/auth/session', 200, {
ok: true,
data: { user: { username: 'alice' }, permissions: {} },
});
await act('check');
assertEq(getModel('auth').data.ttl, 900 * 1000, 'fallback to stored ttl');
});
test('check 200: non-JWT stored token falls back to the stored TTL', async () => {
const s = setup(); // default storage carries the non-JWT 'access-old'
s.route('/api/auth/session', 200, {
ok: true,
data: { user: { username: 'alice' }, permissions: {} },
});
await act('check');
assertEq(getModel('auth').data.ttl, 900 * 1000, 'fallback to stored ttl');
});
test('refresh action: rotated ttl comes from the new token\'s exp claim', async () => {
const s = setup();
s.route('/api/auth/refresh', 200, {
ok: true,
data: {
tokens: { access_token: makeJwt(450), refresh_token: 'r2', session_id: 's2' },
access_ttl: 300, // full TTL — must lose to the exp claim
user: { username: 'alice' },
permissions: { firewall: 'rw' },
},
});
await act('refresh');
const ttl = getModel('auth').data.ttl;
assert(ttl > 440 * 1000 && ttl <= 450 * 1000,
`exp-based ttl (~450s), not access_ttl 300s: got ${ttl}`);
});
test('login action: ttl comes from the issued token\'s exp claim', async () => {
const s = setup();
await modelFetch('auth', {
action: 'login',
payload: {
tokens: { access_token: makeJwt(900), refresh_token: 'r1', session_id: 's1' },
access_ttl: 900,
user: { username: 'alice' },
permissions: { firewall: 'rw' },
},
});
const ttl = getModel('auth').data.ttl;
assert(ttl > 890 * 1000 && ttl <= 900 * 1000,
`exp-based ttl (~900s): got ${ttl}`);
});
/* ── Runner ────────────────────────────────────────────────── */ /* ── Runner ────────────────────────────────────────────────── */
(async () => { (async () => {
+244
View File
@@ -0,0 +1,244 @@
/**
* Tests for hoover/render.js component lifecycle (per-container #comp registry).
*
* Regression: the #comp lifecycle registry and expanded-content cache were
* module-globals, pruned per-container inside normalizeVNodesWithLifecycle().
* Because commitAll() commits #sidebar (no #comp) before #main (the page
* #comp), every sidebar commit unmounted+pruned the page from the global
* registry, so the following #main commit treated the page as newly mounted
* and re-ran load(). For pages whose load() re-mutates reactive state with
* fresh values each run (passkeys.js, users.js), every re-run scheduled
* another commit — an infinite unmount/remount/load loop (~100 fetches/s),
* leaving the page stuck on "Loading...".
*
* render.js pulls in vdom.js + component.js — DOM-only at commit time, so the
* tests run under plain node with a minimal fake DOM (same pattern as
* test-auth-model.js / test-model-set.js).
*
* Run with `node tests/test-render-lifecycle.js`
* (optional arg 1: hoover root, defaults to ../webui/static/hoover).
*
* NOTE: against buggy (global-registry) code the self-mutation test spins the
* infinite remount loop and saturates the event loop — the process hangs
* instead of failing an assertion (mirrors the live symptom). Run under an
* external `timeout` when checking old checkouts:
* timeout 30 node tests/test-render-lifecycle.js <hoover-root>
*/
import { pathToFileURL } from 'node:url';
import path from 'node:path';
const HOOVER_ROOT = process.argv[2]
? pathToFileURL(path.resolve(process.argv[2])).href + '/'
: new URL('../webui/static/hoover/', import.meta.url).href;
/* ── Minimal fake DOM ───────────────────────────────────────── */
class FakeEl {
constructor(tag) {
this.tagName = String(tag || 'div').toUpperCase();
this.nodeType = 1;
this.childNodes = [];
this.parentNode = null;
this.style = { cssText: '' };
this.attributes = {};
this._listeners = {};
this.className = '';
this.value = '';
this.checked = false;
this.selected = false;
this.disabled = false;
}
get firstChild() { return this.childNodes[0] || null; }
setAttribute(k, v) { this.attributes[k] = String(v); }
removeAttribute(k) { delete this.attributes[k]; }
appendChild(c) {
if (c.parentNode) c.parentNode.removeChild(c);
c.parentNode = this;
this.childNodes.push(c);
return c;
}
insertBefore(c, ref) {
if (c.parentNode) c.parentNode.removeChild(c);
c.parentNode = this;
const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length;
this.childNodes.splice(i === -1 ? this.childNodes.length : i, 0, c);
return c;
}
removeChild(c) {
const i = this.childNodes.indexOf(c);
if (i !== -1) this.childNodes.splice(i, 1);
c.parentNode = null;
return c;
}
replaceChild(nd, od) {
const i = this.childNodes.indexOf(od);
if (i !== -1) this.childNodes[i] = nd;
od.parentNode = null;
nd.parentNode = this;
return od;
}
addEventListener(ev, fn) { (this._listeners[ev] ||= []).push(fn); }
removeEventListener(ev, fn) {
const arr = this._listeners[ev] || [];
const i = arr.indexOf(fn);
if (i !== -1) arr.splice(i, 1);
}
}
class FakeText {
constructor(text) { this.nodeType = 3; this.nodeValue = String(text); this.parentNode = null; }
}
globalThis.document = {
createElement: (tag) => new FakeEl(tag),
createTextNode: (t) => new FakeText(t),
};
globalThis.window = { addEventListener: () => {} };
/* ── Imports (dynamic: hoover root is injectable) ───────────── */
const { reactive } = await import(HOOVER_ROOT + 'reactivity.js');
const { h } = await import(HOOVER_ROOT + 'vdom.js');
const { render } = await import(HOOVER_ROOT + 'render.js');
const { definePage, hComp } = await import(HOOVER_ROOT + 'component.js');
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) { tests.push({ name, fn }); }
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${a}, want ${b}`);
}
const flush = () => new Promise(r => setTimeout(r, 20));
/**
* Build a page whose load() mutates reactive state (like passkeys.js
* loadCredentials: refreshing=true before the fetch, credentials=<new array>
* and refreshing=false after — fresh values on every run).
*/
function makePage(label, counters) {
const state = reactive({ loading: true, done: 0 });
return {
state,
page: definePage({
init: () => state,
async load(s) {
counters.loads++;
counters.loadKeys.push(label);
s.done = (s.done || 0) + 1; // fresh value every run → schedules a commit
s.loading = false;
},
onUnmount: () => { counters.unmounts++; counters.unmountKeys.push(label); },
render: () => h('div', { class: 'card' }, `${label}-body`),
}),
};
}
const freshCounters = () => ({ loads: 0, unmounts: 0, loadKeys: [], unmountKeys: [] });
test('initial mount runs load() exactly once', async () => {
const c = freshCounters();
const { page } = makePage('A', c);
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(page, '/page-a'));
await flush();
assertEq(c.loads, 1, 'load ran once');
assertEq(c.unmounts, 0, 'no unmounts');
});
test('external reactive update does NOT re-mount the page', async () => {
const c = freshCounters();
const { page } = makePage('A', c);
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(page, '/page-a'));
await flush();
assertEq(c.loads, 1, 'baseline');
// Simulate a WS tick / toast / any reactive mutation outside the page.
const external = reactive({ n: 1 });
for (let i = 0; i < 3; i++) {
external.n += 1;
await flush();
}
assertEq(c.loads, 1, 'load still ran exactly once after 3 external updates');
assertEq(c.unmounts, 0, 'page was never unmounted');
});
test('page load() self-mutations do not re-trigger load (no infinite loop)', async () => {
const c = freshCounters();
const { page } = makePage('A', c);
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(page, '/page-a'));
// load() mutates reactive state on every run — give the (buggy) loop time
// to spin. With the per-container registry it must stay at exactly one run.
await flush();
await flush();
await flush();
assertEq(c.loads, 1, 'no remount loop driven by load\'s own state mutations');
assertEq(c.unmounts, 0, 'no spurious unmounts');
});
test('navigation unmounts the old page once and mounts the new page once', async () => {
const c = freshCounters();
const a = makePage('A', c);
const b = makePage('B', c);
const nav = reactive({ path: '/page-a' });
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
await flush();
assertEq(c.loads, 1, 'A mounted');
nav.path = '/page-b';
await flush();
assertEq(c.loads, 2, 'B mounted once');
assertEq(c.unmounts, 1, 'A unmounted once');
assertEq(c.unmountKeys[0], 'A', 'A was the unmounted page');
// navigate back — A mounts again with preserved state (load re-runs by design)
nav.path = '/page-a';
await flush();
assertEq(c.loads, 3, 'A re-mounted after navigation back');
assertEq(c.unmounts, 2, 'B unmounted');
assertEq(a.state.done, 2, 'A state preserved across unmount (2 loads total)');
});
test('two #comp containers: updates in one root do not disturb the other', async () => {
const c = freshCounters();
const left = makePage('L', c);
const right = makePage('R', c);
const l = new FakeEl('div');
const r = new FakeEl('div');
render(l, () => hComp(left.page, '/left'));
render(r, () => hComp(right.page, '/right'));
await flush();
assertEq(c.loads, 2, 'both pages mounted');
const external = reactive({ n: 1 });
for (let i = 0; i < 3; i++) { external.n += 1; await flush(); }
assertEq(c.loads, 2, 'neither page re-mounted');
assertEq(c.unmounts, 0, 'neither page unmounted');
});
/* ── Runner ─────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+39 -4
View File
@@ -73,14 +73,17 @@ export function createAuthModel() {
const json = await r.json(); const json = await r.json();
if (!json.ok || !json.data?.user) return null; if (!json.ok || !json.data?.user) return null;
// Server returns ONLY { user, permissions } — merge verified identity // Server returns ONLY { user, permissions } — merge verified identity
// onto the stored token state. // onto the stored token state. TTL is the token's REMAINING
// lifetime (exp claim), not the full issued TTL — the in-memory
// timer must fire before the actual expiry even when the session
// was restored mid-life (page reload/restore).
return { return {
token: stored.access, token: stored.access,
refresh: stored.refresh, refresh: stored.refresh,
session_id: stored.session_id, session_id: stored.session_id,
user: json.data.user, user: json.data.user,
permissions: json.data.permissions, permissions: json.data.permissions,
ttl: stored.ttl || 900 * 1000, ttl: tokenRemainingTtlMs(stored.access, stored.ttl || 900 * 1000),
}; };
} }
@@ -99,7 +102,10 @@ export function createAuthModel() {
session_id: payload.tokens.session_id, session_id: payload.tokens.session_id,
user: payload.user, user: payload.user,
permissions: payload.permissions, permissions: payload.permissions,
ttl: (payload.access_ttl || 900) * 1000, ttl: tokenRemainingTtlMs(
payload.tokens.access_token,
(payload.access_ttl || 900) * 1000
),
}; };
} }
@@ -185,10 +191,39 @@ async function _doRefresh() {
session_id: t.session_id, session_id: t.session_id,
user: json.data.user ?? prev?.user, user: json.data.user ?? prev?.user,
permissions: json.data.permissions ?? prev?.permissions, permissions: json.data.permissions ?? prev?.permissions,
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000), ttl: tokenRemainingTtlMs(
t.access_token,
json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000)
),
}; };
} }
/**
* Remaining lifetime (ms) of an access token from its unverified `exp` claim.
* The payload is decoded WITHOUT signature verification — this mirrors the
* server's own unverified-payload extraction (lib/auth.py) and is used only
* to schedule the refresh timer, never to trust the claim. Returns the
* fallback when the token is malformed, undecodable, or already expired.
* @param {string} token - JWT access token
* @param {number} fallbackMs - TTL in ms when the exp claim is unusable
* @returns {number} remaining ms (> 0) or fallbackMs
*/
function tokenRemainingTtlMs(token, fallbackMs) {
try {
const payloadB64 = String(token).split('.')[1];
if (!payloadB64) return fallbackMs;
const padded = payloadB64 + '===='.slice(0, (4 - (payloadB64.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
if (payload && typeof payload.exp === 'number') {
const remaining = payload.exp * 1000 - Date.now();
if (remaining > 0) return remaining;
}
} catch {
/* malformed token — fall back to the configured TTL */
}
return fallbackMs;
}
/** /**
* Read stored token state from sessionStorage. * Read stored token state from sessionStorage.
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}} * @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
+2 -3
View File
@@ -17,7 +17,6 @@
import { reactive } from './reactivity.js'; import { reactive } from './reactivity.js';
import { h } from './vdom.js'; import { h } from './vdom.js';
import { _compExpandedCache } from './render.js';
/** Registry of mounted components: key → { state } */ /** Registry of mounted components: key → { state } */
const _mounted = new Map(); const _mounted = new Map();
@@ -90,7 +89,7 @@ export function mountComponent(key, renderer) {
* Unmount a page component. Called by the render engine when a #comp vnode * Unmount a page component. Called by the render engine when a #comp vnode
* is removed from the tree. * is removed from the tree.
*/ */
export function unmountComponent(key, renderer) { export function unmountComponent(key, renderer, compCache) {
const entry = _mounted.get(key); const entry = _mounted.get(key);
if (!entry) return; if (!entry) return;
@@ -103,7 +102,7 @@ export function unmountComponent(key, renderer) {
try { pd.onUnmount(entry.state); } catch (_) {} try { pd.onUnmount(entry.state); } catch (_) {}
} }
_compExpandedCache.delete(key); if (compCache) compCache.delete(key);
_mounted.delete(key); _mounted.delete(key);
} }
+46 -22
View File
@@ -18,11 +18,31 @@ export const _renderSlots = new Map();
/** Container → render function */ /** Container → render function */
export const _renderFns = new Map(); export const _renderFns = new Map();
/** Component key → last normalized #comp output (for _vnodeDom preservation) */ /** Container → (component key → last normalized #comp output, for _vnodeDom preservation) */
export const _compExpandedCache = new Map(); const _compExpandedCaches = new Map();
/** Component key → renderer function (survives normalization that expands #comp) */ /** Container → (component key → renderer function). Per-container: a commit of one
const _compRegistry = new Map(); * 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. * Set up lifecycle callback hooks from vdom.js.
@@ -69,8 +89,9 @@ function commit(container) {
if (typeof result === 'function') result = result(); if (typeof result === 'function') result = result();
const prev = _renderSlots.get(container); const prev = _renderSlots.get(container);
// Normalize: expand #comp vnodes and track lifecycle // Normalize: expand #comp vnodes and track lifecycle (this container's own
const vnodes = normalizeVNodesWithLifecycle(result, prev); // registry — other roots' commits must not touch our component keys).
const vnodes = normalizeVNodesWithLifecycle(result, prev, container);
if (!prev) { if (!prev) {
for (const v of vnodes) { for (const v of vnodes) {
@@ -89,16 +110,18 @@ function commit(container) {
* Normalize render output: filter nulls, expand #comp vnodes, * Normalize render output: filter nulls, expand #comp vnodes,
* and manage component lifecycle based on key changes. * and manage component lifecycle based on key changes.
*/ */
function normalizeVNodesWithLifecycle(result, prevVnodes) { function normalizeVNodesWithLifecycle(result, prevVnodes, container) {
const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer })); 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 oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
const newEntries = []; const newEntries = [];
const normalized = normalizeRecursive(result, oldKeyMap, newEntries); const normalized = normalizeRecursive(result, oldKeyMap, newEntries, null, compCache);
for (const entry of oldEntries) { for (const entry of oldEntries) {
if (!newEntries.some(e => e.key === entry.key)) { if (!newEntries.some(e => e.key === entry.key)) {
unmountComponent(entry.key, entry.renderer); unmountComponent(entry.key, entry.renderer, compCache);
} }
} }
for (const entry of newEntries) { 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, // Sync this container's registry with the current render (prevVnodes are
// so collectCompEntries always returns [] after the first render) // normalized and lack #comp tags, so collectCompEntries always returns []
// after the first render)
const newKeySet = new Set(newEntries.map(e => e.key)); const newKeySet = new Set(newEntries.map(e => e.key));
for (const [key] of _compRegistry) { for (const [key] of registry) {
if (!newKeySet.has(key)) _compRegistry.delete(key); if (!newKeySet.has(key)) registry.delete(key);
} }
for (const entry of newEntries) { for (const entry of newEntries) {
_compRegistry.set(entry.key, entry.renderer); registry.set(entry.key, entry.renderer);
} }
return normalized; return normalized;
@@ -127,13 +151,13 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
* When prevCh is provided, preserves _vnodeDom entries so that diff * When prevCh is provided, preserves _vnodeDom entries so that diff
* can locate existing DOM after normalization creates new vnode objects. * 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 (result == null) return [];
if (Array.isArray(result)) { if (Array.isArray(result)) {
const flat = []; const flat = [];
let idx = 0; let idx = 0;
for (const item of result) { 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++; idx++;
} }
return flat; return flat;
@@ -152,19 +176,19 @@ function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
} }
if (renderer && typeof renderer === 'function') { if (renderer && typeof renderer === 'function') {
const content = renderer(); const content = renderer();
const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null; const prevExpanded = key !== undefined && compCache ? compCache.get(key) : null;
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded); const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded, compCache);
if (key !== undefined) _compExpandedCache.set(key, result); if (key !== undefined && compCache) compCache.set(key, result);
return result; return result;
} }
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh); return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh, compCache);
} }
const rawChildren = vnode.ch || []; const rawChildren = vnode.ch || [];
const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null; const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null;
const children = []; const children = [];
for (let i = 0; i < rawChildren.length; i++) { 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); children.push(...normalized);
} }