a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 7.1 kB view raw
1/** 2 * Global lifecycle hook system for Volt.js 3 * Provides beforeMount, afterMount, beforeUnmount, and afterUnmount hooks 4 */ 5 6import type { ElementLifecycleState, GlobalHookName, MountHookCallback, Scope, UnmountHookCallback } from "$types/volt"; 7import { report } from "./error"; 8 9/** 10 * Global lifecycle hooks registry 11 */ 12const lifecycleHooks = new Map<GlobalHookName, Set<MountHookCallback | UnmountHookCallback>>([ 13 ["beforeMount", new Set()], 14 ["afterMount", new Set()], 15 ["beforeUnmount", new Set()], 16 ["afterUnmount", new Set()], 17]); 18 19const elementLifecycleStates = new WeakMap<Element, ElementLifecycleState>(); 20 21/** 22 * Register a global lifecycle hook. 23 * Global hooks run for every mount/unmount operation in the application. 24 * 25 * @param name - Name of the lifecycle hook 26 * @param cb - Callback function to execute 27 * @returns Unregister function 28 * 29 * @example 30 * // Log every mount operation 31 * registerGlobalHook('beforeMount', (root, scope) => { 32 * console.log('Mounting', root, 'with scope', scope); 33 * }); 34 * 35 * @example 36 * // Track mounted elements 37 * const mountedElements = new Set<Element>(); 38 * registerGlobalHook('afterMount', (root) => { 39 * mountedElements.add(root); 40 * }); 41 * registerGlobalHook('beforeUnmount', (root) => { 42 * mountedElements.delete(root); 43 * }); 44 */ 45export function registerGlobalHook(name: GlobalHookName, cb: MountHookCallback | UnmountHookCallback): () => void { 46 const hooks = lifecycleHooks.get(name); 47 if (!hooks) { 48 throw new Error(`Unknown lifecycle hook: ${name}`); 49 } 50 51 hooks.add(cb); 52 53 return () => { 54 hooks.delete(cb); 55 }; 56} 57 58/** 59 * Unregister a global lifecycle hook. 60 * 61 * @param name - Name of the lifecycle hook 62 * @param cb - Callback function to remove 63 * @returns true if the hook was removed, false if it wasn't registered 64 */ 65export function unregisterGlobalHook(name: GlobalHookName, cb: MountHookCallback | UnmountHookCallback): boolean { 66 const hooks = lifecycleHooks.get(name); 67 if (!hooks) { 68 return false; 69 } 70 71 return hooks.delete(cb); 72} 73 74/** 75 * Clear all global hooks for a specific lifecycle event. 76 * 77 * @param name - Name of the lifecycle hook to clear 78 */ 79export function clearGlobalHooks(name: GlobalHookName): void { 80 const hooks = lifecycleHooks.get(name); 81 if (hooks) { 82 hooks.clear(); 83 } 84} 85 86export function clearAllGlobalHooks(): void { 87 for (const hooks of lifecycleHooks.values()) { 88 hooks.clear(); 89 } 90} 91 92/** 93 * Get all registered hooks for a specific lifecycle event. 94 * Used internally by the binder system. 95 * 96 * @param name - Name of the lifecycle hook 97 * @returns Array of registered callbacks 98 */ 99export function getGlobalHooks(name: GlobalHookName): Array<MountHookCallback | UnmountHookCallback> { 100 const hooks = lifecycleHooks.get(name); 101 return hooks ? [...hooks] : []; 102} 103 104/** 105 * Execute all registered hooks for a lifecycle event. 106 * Used internally by the binder system. 107 * 108 * @param hookName - Name of the lifecycle hook to execute 109 * @param root - The root element being mounted/unmounted 110 * @param scope - The scope object (only for mount hooks) 111 */ 112export function execGlobalHooks(hookName: GlobalHookName, root: Element, scope?: Scope): void { 113 const hooks = lifecycleHooks.get(hookName); 114 if (!hooks || hooks.size === 0) { 115 return; 116 } 117 118 for (const callback of hooks) { 119 try { 120 if (hookName === "beforeMount" || hookName === "afterMount") { 121 if (scope !== undefined) { 122 (callback as MountHookCallback)(root, scope); 123 } 124 } else { 125 (callback as UnmountHookCallback)(root); 126 } 127 } catch (error) { 128 report(error as Error, { source: "lifecycle", element: root as HTMLElement, hookName: hookName }); 129 } 130 } 131} 132 133/** 134 * Get or create lifecycle state for an element. 135 * 136 * @param element - The element to track 137 * @returns The lifecycle state object 138 */ 139function getElementLifecycleState(element: Element): ElementLifecycleState { 140 let state = elementLifecycleStates.get(element); 141 if (!state) { 142 state = { isMounted: false, bindings: new Set(), onMount: new Set(), onUnmount: new Set() }; 143 elementLifecycleStates.set(element, state); 144 } 145 return state; 146} 147 148/** 149 * Register a per-element lifecycle hook. 150 * These hooks are specific to individual elements. 151 * 152 * @param element - The element to attach the hook to 153 * @param hookType - Type of hook ('mount' or 'unmount') 154 * @param cb - Callback to execute 155 */ 156export function registerElementHook(element: Element, hookType: "mount" | "unmount", cb: () => void): void { 157 const state = getElementLifecycleState(element); 158 159 if (hookType === "mount") { 160 state.onMount.add(cb); 161 } else { 162 state.onUnmount.add(cb); 163 } 164} 165 166/** 167 * Notify that an element has been mounted. 168 * Executes all registered onMount callbacks for the element. 169 * 170 * @param el - The mounted element 171 */ 172export function notifyElementMounted(el: Element): void { 173 const state = getElementLifecycleState(el); 174 175 if (state.isMounted) { 176 return; 177 } 178 179 state.isMounted = true; 180 181 for (const callback of state.onMount) { 182 try { 183 callback(); 184 } catch (error) { 185 report(error as Error, { source: "lifecycle", element: el as HTMLElement, hookName: "onMount" }); 186 } 187 } 188} 189 190/** 191 * Notify that an element is being unmounted. 192 * Executes all registered onUnmount callbacks for the element. 193 * 194 * @param el - The element being unmounted 195 */ 196export function notifyElementUnmounted(el: Element): void { 197 const state = getElementLifecycleState(el); 198 199 if (!state.isMounted) { 200 return; 201 } 202 203 state.isMounted = false; 204 205 for (const callback of state.onUnmount) { 206 try { 207 callback(); 208 } catch (error) { 209 report(error as Error, { source: "lifecycle", element: el as HTMLElement, hookName: "onUnmount" }); 210 } 211 } 212 213 elementLifecycleStates.delete(el); 214} 215 216/** 217 * Notify that a binding has been created on an element. 218 * 219 * @param element - The element the binding is on 220 * @param name - Name of the binding (e.g., 'text', 'class', 'on-click') 221 */ 222export function notifyBindingCreated(element: Element, name: string): void { 223 const state = getElementLifecycleState(element); 224 state.bindings.add(name); 225} 226 227/** 228 * Notify that a binding has been destroyed on an element. 229 * 230 * @param element - The element the binding was on 231 * @param name - Name of the binding 232 */ 233export function notifyBindingDestroyed(element: Element, name: string): void { 234 const state = elementLifecycleStates.get(element); 235 if (state) { 236 state.bindings.delete(name); 237 } 238} 239 240/** 241 * Check if an element is currently mounted. 242 * 243 * @param element - The element to check 244 * @returns true if the element is mounted 245 */ 246export function isElementMounted(element: Element): boolean { 247 const state = elementLifecycleStates.get(element); 248 return state?.isMounted ?? false; 249} 250 251/** 252 * Get all bindings on an element. 253 * 254 * @param element - The element to query 255 * @returns Array of binding names 256 */ 257export function getElementBindings(element: Element): string[] { 258 const state = elementLifecycleStates.get(element); 259 return state ? [...state.bindings] : []; 260}