diff --git a/docs/hoover.md b/docs/hoover.md index c476840..ddc16fe 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -522,6 +522,9 @@ Define a page component with reactive state and rendering. Pages access data thr ```javascript export default definePage({ + // Browser tab title — applied to document.title on mount + title: 'Zones - Vacuum Wall', + // Return initial state — models are obtained via getModel() init() { return { @@ -560,6 +563,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`. | Property | Required | Description | |---|---|---| +| `title` | No | Full browser tab title, applied to `document.title` when the page mounts. Declare on every routed page so the tab title tracks navigation. | | `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. | | `load(state)` | No | Optional one-time setup called on mount. Not used for data loading — use model layer instead. | | `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state..data`. | @@ -567,7 +571,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`. ### Page Lifecycle -1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key. +1. **Mount**: `init()` creates state → tab title set from `title` (if declared) → `load()` fires if defined → component tracked by key. 2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM. 3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`. 4. **Unmount**: `onUnmount()` called if defined → component entry destroyed. @@ -1446,6 +1450,7 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching. ## Conventions - **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`. +- **Tab title**: Pages declare `title: ' - Vacuum Wall'`; `component.js` applies it to `document.title` on mount. No page should set `document.title` directly. - **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`. - **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode. - **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags. diff --git a/tests/test-render-lifecycle.js b/tests/test-render-lifecycle.js index a6b009e..c69be37 100644 --- a/tests/test-render-lifecycle.js +++ b/tests/test-render-lifecycle.js @@ -116,11 +116,12 @@ const flush = () => new Promise(r => setTimeout(r, 20)); * loadCredentials: refreshing=true before the fetch, credentials= * and refreshing=false after — fresh values on every run). */ -function makePage(label, counters) { +function makePage(label, counters, title) { const state = reactive({ loading: true, done: 0 }); return { state, page: definePage({ + title: title || undefined, init: () => state, async load(s) { counters.loads++; @@ -227,6 +228,62 @@ test('two #comp containers: updates in one root do not disturb the other', async assertEq(c.unmounts, 0, 'neither page unmounted'); }); +/* ── Tab title (definePage `title`) ─────────────────────────── */ + +test('mounting a titled page sets document.title', async () => { + const c = freshCounters(); + const { page } = makePage('T', c, 'Titled - Vacuum Wall'); + const main = new FakeEl('div'); + document.title = 'base'; + render(main, () => hComp(page, '/titled')); + await flush(); + assertEq(document.title, 'Titled - Vacuum Wall', 'title applied on mount'); +}); + +test('a page without a title leaves document.title untouched', async () => { + const c = freshCounters(); + const { page } = makePage('U', c); + const main = new FakeEl('div'); + document.title = 'unchanged'; + render(main, () => hComp(page, '/untitled')); + await flush(); + assertEq(document.title, 'unchanged', 'no title → document.title untouched'); +}); + +test('navigation updates document.title; remount re-applies idempotently', async () => { + const c = freshCounters(); + const a = makePage('A', c, 'Alpha - Vacuum Wall'); + const b = makePage('B', c, 'Beta - Vacuum Wall'); + const nav = reactive({ path: '/page-a' }); + const main = new FakeEl('div'); + render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path)); + await flush(); + assertEq(document.title, 'Alpha - Vacuum Wall', 'A title on first mount'); + + nav.path = '/page-b'; + await flush(); + assertEq(document.title, 'Beta - Vacuum Wall', 'B title after navigation'); + + nav.path = '/page-a'; + await flush(); + assertEq(document.title, 'Alpha - Vacuum Wall', 'A title re-applied on remount'); +}); + +test('mounting an untitled page does not reset a previously set title', async () => { + const c = freshCounters(); + const a = makePage('A', c, 'Alpha - Vacuum Wall'); + const b = makePage('B', c); + const nav = reactive({ path: '/page-a' }); + const main = new FakeEl('div'); + render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path)); + await flush(); + assertEq(document.title, 'Alpha - Vacuum Wall', 'baseline'); + + nav.path = '/page-b'; + await flush(); + assertEq(document.title, 'Alpha - Vacuum Wall', 'untitled mount keeps prior title'); +}); + /* ── Runner ─────────────────────────────────────────────────── */ (async () => { for (const { name, fn } of tests) { diff --git a/webui/static/hoover/component.js b/webui/static/hoover/component.js index 97a7f0f..8dff347 100644 --- a/webui/static/hoover/component.js +++ b/webui/static/hoover/component.js @@ -25,6 +25,7 @@ const _mounted = new Map(); * Define a page component. * * @param {object} def — Page definition + * @param {string} [def.title] — Full browser tab title; applied to document.title on mount * @param {function} def.init — Return initial state object * @param {function} [def.load] — Optional one-time setup called on mount * @param {function} def.render — Render function that returns vnodes @@ -52,6 +53,7 @@ export function definePage(def) { }, load: def.load || null, onUnmount: def.onUnmount || null, + title: def.title || null, }; return renderer; @@ -65,6 +67,8 @@ export function mountComponent(key, renderer) { const pd = renderer._pageDef; if (!pd) return; + if (pd.title) document.title = pd.title; + let entry = _mounted.get(key); if (entry) { diff --git a/webui/static/pages/backends.js b/webui/static/pages/backends.js index cdc6c83..bf6b5ac 100644 --- a/webui/static/pages/backends.js +++ b/webui/static/pages/backends.js @@ -256,6 +256,7 @@ export function openBackendModal(state, backend) { // Page // --------------------------------------------------------------------------- export default definePage({ + title: 'Backends - Vacuum Wall', init() { return { backends: getModel('backends'), diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index d45e00b..feb9a30 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -326,6 +326,7 @@ async function pollCertIssue(rid) { } export default definePage({ + title: 'Certificates - Vacuum Wall', init() { return { acme: getModel('acme'), diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index 6445c12..f171782 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -41,6 +41,7 @@ function diffLine(d) { } export default definePage({ + title: 'Dashboard - Vacuum Wall', init() { return { firewall: getModel('firewall'), diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 35a6eba..1d4ae04 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -107,6 +107,7 @@ const addDns = QuickModal({ }); export default definePage({ + title: 'DHCP & DNS - Vacuum Wall', init() { return { dnsmasq: getModel('dnsmasq'), diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js index f06dbce..94399e5 100644 --- a/webui/static/pages/interfaces.js +++ b/webui/static/pages/interfaces.js @@ -35,6 +35,7 @@ const cfgModalFn = QuickModal({ }); export default definePage({ + title: 'Interfaces - Vacuum Wall', init() { return { firewall: getModel('firewall'), diff --git a/webui/static/pages/login.js b/webui/static/pages/login.js index de7dd45..679fcd4 100644 --- a/webui/static/pages/login.js +++ b/webui/static/pages/login.js @@ -205,8 +205,9 @@ const passkeyMouseLeaveHandler = () => { }; const Page = definePage({ + title: 'Login - Vacuum Wall', init() { - document.title = 'Login — Vacuum Wall'; + return {}; }, load() { diff --git a/webui/static/pages/logs.js b/webui/static/pages/logs.js index a8b4210..9bb0c4e 100644 --- a/webui/static/pages/logs.js +++ b/webui/static/pages/logs.js @@ -9,6 +9,7 @@ const logTabs = [ ]; export default definePage({ + title: 'Logs - Vacuum Wall', init() { return { logs: getModel('logs'), diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js index 903e757..258c882 100644 --- a/webui/static/pages/nat.js +++ b/webui/static/pages/nat.js @@ -24,6 +24,7 @@ const addFwd = QuickModal({ }); export default definePage({ + title: 'NAT - Vacuum Wall', init() { return { firewall: getModel('firewall'), diff --git a/webui/static/pages/notfound.js b/webui/static/pages/notfound.js index 1b6504b..15d5920 100644 --- a/webui/static/pages/notfound.js +++ b/webui/static/pages/notfound.js @@ -1,6 +1,7 @@ import { html, PageHeader, definePage } from '/static/hoover/index.js'; export default definePage({ + title: '404 - Vacuum Wall', init() { return { path: location.hash.slice(1) || '' }; }, diff --git a/webui/static/pages/passkeys.js b/webui/static/pages/passkeys.js index 302daf9..58c9ff0 100644 --- a/webui/static/pages/passkeys.js +++ b/webui/static/pages/passkeys.js @@ -257,8 +257,8 @@ function CredentialsPage() { } const Page = definePage({ + title: 'Passkeys - Vacuum Wall', init() { - document.title = 'Passkeys — Vacuum Wall'; return state; }, diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index e8772ee..69fcf23 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -250,6 +250,7 @@ function backendSection(section, state, set) { } export default definePage({ + title: 'Proxy - Vacuum Wall', init() { return { nginx: getModel('nginx'), diff --git a/webui/static/pages/rules.js b/webui/static/pages/rules.js index 9248ee7..d627c59 100644 --- a/webui/static/pages/rules.js +++ b/webui/static/pages/rules.js @@ -15,6 +15,7 @@ const addRule = QuickModal({ }); export default definePage({ + title: 'Rules - Vacuum Wall', init() { return { firewall: getModel('firewall'), diff --git a/webui/static/pages/users.js b/webui/static/pages/users.js index c71a89e..7e9b4d6 100644 --- a/webui/static/pages/users.js +++ b/webui/static/pages/users.js @@ -252,6 +252,7 @@ function UsersPage() { } export default definePage({ + title: 'Users - Vacuum Wall', init() { return state; }, diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js index 5bb9086..7b9f30d 100644 --- a/webui/static/pages/wireguard.js +++ b/webui/static/pages/wireguard.js @@ -402,6 +402,7 @@ function renderAccessClasses(config, status) { /* ── Main Page ───────────────────────────────────────────────── */ export default definePage({ + title: 'WireGuard - Vacuum Wall', init() { return { wireguard: getModel('wireguard'), diff --git a/webui/static/pages/zones.js b/webui/static/pages/zones.js index 1a96dfd..a091a9d 100644 --- a/webui/static/pages/zones.js +++ b/webui/static/pages/zones.js @@ -24,6 +24,7 @@ const addZone = QuickModal({ }); export default definePage({ + title: 'Zones - Vacuum Wall', init() { return { firewall: getModel('firewall'),