a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}