/** * 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 and * lifecycle management. Data loading is handled by the model layer. * * Usage: * export default definePage({ * init() { return { firewall: getModel('firewall') }; }, * async load(state) { ... }, // optional, for one-time setup * 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 } */ const _mounted = new Map(); /** * Define a page component. * * @param {object} def — Page definition * @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 * @returns {object} — Component renderer compatible with h('#comp', ...) */ export function definePage(def) { let state = null; let stateInitialized = false; const renderer = () => { if (!stateInitialized) { state = reactive(def.init()); stateInitialized = true; } return def.render(state); }; renderer._pageDef = { get state() { if (!stateInitialized) { state = reactive(def.init()); stateInitialized = true; } return state; }, 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 state. // Abort previous in-flight load and re-run. if (entry.abortController) entry.abortController.abort(); entry.abortController = null; } else { entry = { state: pd.state }; _mounted.set(key, entry); pd.state.error = null; } if (pd.load) { const abortController = new AbortController(); entry.abortController = abortController; Promise.resolve().then(() => pd.load(pd.state, abortController)); } } /** * 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; // Abort in-flight load requests so they don't mutate unmounted state if (entry.abortController) entry.abortController.abort(); 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 }, []); }