Files
vacuum-wall/webui/static/hoover/component.js
T
mteehan 633505e7dc refactor: modernize frontend with hoover framework components and docs
- Add quick modal, table, service status, and confirmation dialog components
- Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns
- Introduce refactor load utility and render guard for consistent UX
- Add hoover documentation and update AGENTS.md, architecture, overview
2026-06-21 04:29:27 +00:00

176 lines
5.1 KiB
JavaScript

/**
* Hoover — component.js
*
* Component wrapper: definePage, lifecycle hooks, state caching.
*
* definePage wraps a page definition into a renderer function compatible
* with hoover's render engine. Handles reactive state creation, WS
* subscription registration on mount, and cleanup on unmount.
*
* Usage:
* export default definePage({
* init() { return { data: null, loading: true, error: null }; },
* subscribe: ['*'], // WS topics to subscribe to
* async load(state) { ... }, // called on mount
* render(state) { return [vnodes],
* });
*/
import { reactive } from './reactivity.js?v=6';
import { h } from './vdom.js?v=6';
import { _compExpandedCache } from './render.js?v=6';
/** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */
const _mounted = new Map();
/** Check whether a state object belongs to a currently mounted component.
* Used by websocket.js to skip auto-refresh for unmounted pages. */
export function isComponentStateMounted(state) {
for (const entry of _mounted.values()) {
if (entry.state === state) return true;
}
return false;
}
/** Get the full mounted entry for a state object.
* Used by websocket.js to abort in-flight loads before triggering a refresh. */
export function getComponentEntry(state) {
for (const entry of _mounted.values()) {
if (entry.state === state) return entry;
}
return null;
}
/** External subscribe function from websocket.js.
* Set via setSubscribeFn() when the websocket module initializes.
*/
let _subscribeFn = null;
export function setSubscribeFn(fn) {
_subscribeFn = fn;
}
/**
* Define a page component.
*
* @param {object} def — Page definition
* @param {function} def.init — Return initial state object
* @param {string[]} [def.subscribe] — WS topics to subscribe to on mount
* @param {function} def.load — Async function to load data into state
* @param {function} def.render — Render function that returns vnodes
* @returns {object} — Component renderer compatible with h('#comp', ...)
*/
export function definePage(def) {
const state = reactive(def.init());
const renderer = () => {
return def.render(state);
};
renderer._pageDef = {
state,
subscribe: def.subscribe || [],
load: def.load || null,
onUnmount: def.onUnmount || null,
};
return renderer;
}
/**
* Mount a page component. Called by the render engine when a #comp vnode
* enters the tree for the first time.
*/
export function mountComponent(key, renderer) {
const pd = renderer._pageDef;
if (!pd) return;
let entry = _mounted.get(key);
if (entry) {
// Re-mount: component already exists with its data and subscriptions.
// Don't abort or restart loads — that re-render was triggered by a
// state change (load completion, reactive update, etc). Let existing
// in-flight loads complete naturally. WS handles auto-refresh.
return;
} else {
// Fresh mount
entry = {
state: pd.state,
subscriptions: [],
loadAbort: null,
requestId: 0,
};
_mounted.set(key, entry);
}
// Clear error on re-mount; load() decides loading vs refreshing
pd.state.error = null;
// Fire load with fresh AbortController
if (pd.load) {
if (entry.isLoading) return;
const abortController = new AbortController();
entry.loadAbort = abortController;
entry.requestId++;
entry.isLoading = true;
Promise.resolve()
.then(() => pd.load(pd.state, abortController, entry))
.finally(() => { entry.isLoading = false; });
}
// Register WS subscriptions (only on fresh mount)
if (!entry.subscriptions.length && _subscribeFn && pd.subscribe.length) {
for (const topic of pd.subscribe) {
const unsub = _subscribeFn(renderer, topic, pd.load, pd.state);
if (unsub) entry.subscriptions.push(unsub);
}
}
}
/**
* Unmount a page component. Called by the render engine when a #comp vnode
* is removed from the tree.
*/
export function unmountComponent(key, renderer) {
const entry = _mounted.get(key);
if (!entry) return;
const pd = renderer._pageDef;
// Cancel load
if (entry.loadAbort) {
entry.loadAbort.abort();
}
// Invalidate any in-flight callbacks
entry.requestId++;
// Unsubscribe from WS
for (const unsub of entry.subscriptions) {
try { unsub(); } catch (_) {}
}
// Fire custom onUnmount
if (pd.onUnmount) {
try { pd.onUnmount(entry.state); } catch (_) {}
}
_compExpandedCache.delete(key);
_mounted.delete(key);
}
/**
* Get the state of a mounted component.
*/
export function getComponentState(key) {
const entry = _mounted.get(key);
return entry ? entry.state : null;
}
/**
* Create a component vnode that the render engine will wire up to lifecycle.
*/
export function hComp(renderer, key) {
return h('#comp', { component: renderer, key }, []);
}