From 2b7fe1f48576c3fcea740f5903e79e17120e54f9 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Thu, 3 Sep 2026 17:25:22 +0000 Subject: [PATCH] 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 --- docs/hoover.md | 27 +++- tests/test-auth-model.js | 112 ++++++++++++++ tests/test-render-lifecycle.js | 244 ++++++++++++++++++++++++++++++ webui/static/hoover/auth_model.js | 43 +++++- webui/static/hoover/component.js | 5 +- webui/static/hoover/render.js | 68 ++++++--- 6 files changed, 466 insertions(+), 33 deletions(-) create mode 100644 tests/test-render-lifecycle.js diff --git a/docs/hoover.md b/docs/hoover.md index 3a59e21..c476840 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -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 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), -session validation, login/logout transitions, and WS reconnection coordination. +internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL − 60s +timer, driven by the token's `exp` claim), session validation, login/logout transitions, and WS +reconnection coordination. Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()` (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' }) - → 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 reload/restore): exactly one refresh attempt, then the same success or terminal path @@ -346,7 +349,7 @@ app bootstrap → modelFetch('auth', { action: 'check' }) apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' }) → onSuccess stores rotated tokens (new session_id) or clears + redirects (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) login → modelFetch('auth', { action: 'login', payload: data }) → 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 (timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant 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 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). @@ -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). +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 // Router pattern — key is the path so navigation to a different page unmounts the old one return hComp(page, this.state.path); diff --git a/tests/test-auth-model.js b/tests/test-auth-model.js index 288cff3..db5dbe0 100644 --- a/tests/test-auth-model.js +++ b/tests/test-auth-model.js @@ -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'); }); +/* ── 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 ────────────────────────────────────────────────── */ (async () => { diff --git a/tests/test-render-lifecycle.js b/tests/test-render-lifecycle.js new file mode 100644 index 0000000..a6b009e --- /dev/null +++ b/tests/test-render-lifecycle.js @@ -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 + */ + +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= + * 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; +})(); diff --git a/webui/static/hoover/auth_model.js b/webui/static/hoover/auth_model.js index 1fa8290..fa98d54 100644 --- a/webui/static/hoover/auth_model.js +++ b/webui/static/hoover/auth_model.js @@ -73,14 +73,17 @@ export function createAuthModel() { const json = await r.json(); if (!json.ok || !json.data?.user) return null; // 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 { token: stored.access, refresh: stored.refresh, session_id: stored.session_id, user: json.data.user, 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, user: payload.user, 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, user: json.data.user ?? prev?.user, 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. * @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}} diff --git a/webui/static/hoover/component.js b/webui/static/hoover/component.js index 11b876f..97a7f0f 100644 --- a/webui/static/hoover/component.js +++ b/webui/static/hoover/component.js @@ -17,7 +17,6 @@ import { reactive } from './reactivity.js'; import { h } from './vdom.js'; -import { _compExpandedCache } from './render.js'; /** Registry of mounted components: key → { state } */ 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 * is removed from the tree. */ -export function unmountComponent(key, renderer) { +export function unmountComponent(key, renderer, compCache) { const entry = _mounted.get(key); if (!entry) return; @@ -103,7 +102,7 @@ export function unmountComponent(key, renderer) { try { pd.onUnmount(entry.state); } catch (_) {} } - _compExpandedCache.delete(key); + if (compCache) compCache.delete(key); _mounted.delete(key); } diff --git a/webui/static/hoover/render.js b/webui/static/hoover/render.js index 1b62010..c24d343 100644 --- a/webui/static/hoover/render.js +++ b/webui/static/hoover/render.js @@ -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); }