/** * 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 } */ const _mounted = new Map(); /** * 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) { // Prevent duplicate mounts when normalization loses #comp tracking if (_mounted.has(key)) return; const pd = renderer._pageDef; if (!pd) return; const entry = { state: pd.state, subscriptions: [], loadAbort: null, }; _mounted.set(key, entry); // Fire load if (pd.load) { const abortController = new AbortController(); entry.loadAbort = abortController; pd.load(pd.state, abortController); } // Register WS subscriptions if (_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(); } // 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 }, []); }