/** * 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'; import { h } from './vdom.js'; import { _compExpandedCache } from './render.js'; /** 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; } /** 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 of an already-mounted page: restart load with fresh AbortController if (entry.loadAbort) { entry.loadAbort.abort(); } entry.requestId++; entry.loadAbort = null; } 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) { 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 }, []); }