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:
@@ -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}}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user