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:
+23
-4
@@ -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);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user