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