a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import {
3 clearAllGlobalHooks,
4 clearGlobalHooks,
5 getElementBindings,
6 isElementMounted,
7 notifyElementMounted,
8 notifyElementUnmounted,
9 registerElementHook,
10 registerGlobalHook,
11 unregisterGlobalHook,
12} from "$core/lifecycle";
13import { registerPlugin } from "$core/plugin";
14import { signal } from "$core/signal";
15import type { PluginContext } from "$types/volt";
16import { afterEach, describe, expect, it, vi } from "vitest";
17
18describe("lifecycle hooks", () => {
19 afterEach(() => {
20 clearAllGlobalHooks();
21 });
22
23 describe("global lifecycle hooks", () => {
24 describe("beforeMount", () => {
25 it("executes before mount", () => {
26 const executionOrder: string[] = [];
27 const root = document.createElement("div");
28 root.innerHTML = "<div data-volt-text=\"message\"></div>";
29
30 registerGlobalHook("beforeMount", () => {
31 executionOrder.push("beforeMount");
32 });
33
34 const message = signal("test");
35 executionOrder.push("before mount call");
36 mount(root, { message });
37 executionOrder.push("after mount call");
38
39 expect(executionOrder).toEqual(["before mount call", "beforeMount", "after mount call"]);
40 });
41
42 it("receives root and scope", () => {
43 let receivedRoot: Element | undefined;
44 let receivedScope: Record<string, unknown> | undefined;
45
46 const root = document.createElement("div");
47 const message = signal("test");
48
49 registerGlobalHook("beforeMount", (element: Element, scope: Record<string, unknown>) => {
50 receivedRoot = element;
51 receivedScope = scope;
52 });
53
54 mount(root, { message });
55
56 expect(receivedRoot).toBeDefined();
57 expect(receivedRoot).toBe(root);
58 expect(receivedScope!.message).toBe(message);
59 expect(receivedScope!.$store).toBeDefined();
60 expect(receivedScope!.$arc).toBeDefined();
61 });
62
63 it("can register multiple hooks", () => {
64 const hooks: number[] = [];
65 const root = document.createElement("div");
66
67 registerGlobalHook("beforeMount", () => {
68 hooks.push(1);
69 });
70 registerGlobalHook("beforeMount", () => {
71 hooks.push(2);
72 });
73
74 mount(root, {});
75
76 expect(hooks).toEqual([1, 2]);
77 });
78 });
79
80 describe("afterMount", () => {
81 it("executes after mount completes", () => {
82 const executionOrder: string[] = [];
83 const root = document.createElement("div");
84 root.innerHTML = "<div data-volt-text=\"message\"></div>";
85
86 registerGlobalHook("afterMount", () => {
87 executionOrder.push("afterMount");
88 });
89
90 const message = signal("test");
91 executionOrder.push("before mount");
92 mount(root, { message });
93 executionOrder.push("after mount");
94
95 expect(executionOrder).toEqual(["before mount", "afterMount", "after mount"]);
96 });
97
98 it("executes after mount completes", () => {
99 const root = document.createElement("div");
100 root.innerHTML = "<div data-volt-text=\"message\"></div>";
101
102 let mountCompleted = false;
103
104 registerGlobalHook("afterMount", () => {
105 mountCompleted = true;
106 });
107
108 const message = signal("hello");
109 mount(root, { message });
110
111 expect(mountCompleted).toBe(true);
112 });
113 });
114
115 describe("beforeUnmount", () => {
116 it("executes before unmount", () => {
117 const executionOrder: string[] = [];
118 const root = document.createElement("div");
119
120 registerGlobalHook("beforeUnmount", () => {
121 executionOrder.push("beforeUnmount");
122 });
123
124 const cleanup = mount(root, {});
125
126 executionOrder.push("before cleanup");
127 cleanup();
128 executionOrder.push("after cleanup");
129
130 expect(executionOrder).toEqual(["before cleanup", "beforeUnmount", "after cleanup"]);
131 });
132
133 it("executes before bindings are destroyed", () => {
134 const root = document.createElement("div");
135 root.innerHTML = "<div data-volt-text=\"message\"></div>";
136
137 let wasMounted = false;
138
139 registerGlobalHook("beforeUnmount", () => {
140 wasMounted = true;
141 });
142
143 const message = signal("hello");
144 const cleanup = mount(root, { message });
145
146 cleanup();
147
148 expect(wasMounted).toBe(true);
149 });
150 });
151
152 describe("afterUnmount", () => {
153 it("executes after unmount completes", () => {
154 const executionOrder: string[] = [];
155 const root = document.createElement("div");
156
157 registerGlobalHook("afterUnmount", () => {
158 executionOrder.push("afterUnmount");
159 });
160
161 const cleanup = mount(root, {});
162
163 executionOrder.push("before cleanup");
164 cleanup();
165 executionOrder.push("after cleanup");
166
167 expect(executionOrder).toEqual(["before cleanup", "afterUnmount", "after cleanup"]);
168 });
169 });
170
171 describe("hook registration management", () => {
172 it("can unregister hooks", () => {
173 const hook = vi.fn();
174 const root = document.createElement("div");
175
176 const unregister = registerGlobalHook("beforeMount", hook);
177
178 mount(root, {});
179 expect(hook).toHaveBeenCalledTimes(1);
180
181 unregister();
182
183 mount(root, {});
184 expect(hook).toHaveBeenCalledTimes(1);
185 });
186
187 it("unregisterGlobalHook removes hooks", () => {
188 const hook = vi.fn();
189 const root = document.createElement("div");
190
191 registerGlobalHook("beforeMount", hook);
192
193 mount(root, {});
194 expect(hook).toHaveBeenCalledTimes(1);
195
196 unregisterGlobalHook("beforeMount", hook);
197
198 mount(root, {});
199 expect(hook).toHaveBeenCalledTimes(1);
200 });
201
202 it("clearGlobalHooks removes all hooks for a lifecycle event", () => {
203 const hook1 = vi.fn();
204 const hook2 = vi.fn();
205 const root = document.createElement("div");
206
207 registerGlobalHook("beforeMount", hook1);
208 registerGlobalHook("beforeMount", hook2);
209
210 clearGlobalHooks("beforeMount");
211
212 mount(root, {});
213
214 expect(hook1).not.toHaveBeenCalled();
215 expect(hook2).not.toHaveBeenCalled();
216 });
217
218 it("clearAllGlobalHooks removes all hooks", () => {
219 const beforeMountHook = vi.fn();
220 const afterMountHook = vi.fn();
221 const root = document.createElement("div");
222
223 registerGlobalHook("beforeMount", beforeMountHook);
224 registerGlobalHook("afterMount", afterMountHook);
225
226 clearAllGlobalHooks();
227
228 mount(root, {});
229
230 expect(beforeMountHook).not.toHaveBeenCalled();
231 expect(afterMountHook).not.toHaveBeenCalled();
232 });
233 });
234
235 describe("error handling", () => {
236 it("catches and logs errors in beforeMount hooks", () => {
237 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
238 const root = document.createElement("div");
239
240 registerGlobalHook("beforeMount", () => {
241 throw new Error("beforeMount error");
242 });
243
244 expect(() => {
245 mount(root, {});
246 }).not.toThrow();
247
248 expect(consoleErrorSpy).toHaveBeenCalledTimes(3);
249 expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[lifecycle]"));
250 expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error));
251 expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", root);
252
253 consoleErrorSpy.mockRestore();
254 });
255
256 it("continues executing other hooks after error", () => {
257 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
258 const hook2 = vi.fn();
259 const root = document.createElement("div");
260
261 registerGlobalHook("beforeMount", () => {
262 throw new Error("Error");
263 });
264 registerGlobalHook("beforeMount", hook2);
265
266 mount(root, {});
267
268 expect(hook2).toHaveBeenCalled();
269
270 consoleErrorSpy.mockRestore();
271 });
272 });
273 });
274
275 describe("element lifecycle", () => {
276 it("tracks element mounted state", () => {
277 const element = document.createElement("div");
278
279 expect(isElementMounted(element)).toBe(false);
280 notifyElementMounted(element);
281 expect(isElementMounted(element)).toBe(true);
282 notifyElementUnmounted(element);
283 expect(isElementMounted(element)).toBe(false);
284 });
285
286 it("executes onMount callbacks", () => {
287 const callback = vi.fn();
288 const element = document.createElement("div");
289
290 registerElementHook(element, "mount", callback);
291 notifyElementMounted(element);
292
293 expect(callback).toHaveBeenCalledTimes(1);
294 });
295
296 it("executes onUnmount callbacks", () => {
297 const callback = vi.fn();
298 const element = document.createElement("div");
299
300 registerElementHook(element, "unmount", callback);
301 notifyElementMounted(element);
302 notifyElementUnmounted(element);
303
304 expect(callback).toHaveBeenCalledTimes(1);
305 });
306
307 it("only executes onMount once", () => {
308 const callback = vi.fn();
309 const element = document.createElement("div");
310
311 registerElementHook(element, "mount", callback);
312 notifyElementMounted(element);
313 notifyElementMounted(element);
314
315 expect(callback).toHaveBeenCalledTimes(1);
316 });
317
318 it("only executes onUnmount if element was mounted", () => {
319 const callback = vi.fn();
320 const element = document.createElement("div");
321
322 registerElementHook(element, "unmount", callback);
323 notifyElementUnmounted(element);
324
325 expect(callback).not.toHaveBeenCalled();
326 });
327
328 it("catches and logs errors in element hooks", () => {
329 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
330 const element = document.createElement("div");
331
332 registerElementHook(element, "mount", () => {
333 throw new Error("Mount error");
334 });
335
336 notifyElementMounted(element);
337 expect(consoleErrorSpy).toHaveBeenCalledTimes(3);
338 expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[lifecycle]"));
339 expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error));
340 expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", element);
341
342 consoleErrorSpy.mockRestore();
343 });
344 });
345
346 describe("binding lifecycle", () => {
347 it("tracks mounted state for elements with bindings", () => {
348 const root = document.createElement("div");
349 root.dataset.voltText = "message";
350 const message = signal("test");
351 mount(root, { message });
352 expect(isElementMounted(root)).toBe(true);
353 });
354
355 it("returns empty array for elements with no tracked bindings", () => {
356 const element = document.createElement("div");
357 expect(getElementBindings(element)).toEqual([]);
358 });
359 });
360
361 describe("plugin lifecycle hooks", () => {
362 it("plugin can register mount hooks", () => {
363 const onMountSpy = vi.fn();
364 const root = document.createElement("div");
365 root.dataset.voltCustom = "value";
366
367 registerPlugin("custom", (context: PluginContext) => {
368 context.lifecycle.onMount(() => {
369 onMountSpy();
370 });
371 });
372
373 mount(root, {});
374
375 expect(onMountSpy).toHaveBeenCalled();
376 });
377
378 it("plugin can register unmount hooks", () => {
379 const onUnmountSpy = vi.fn();
380 const root = document.createElement("div");
381 root.dataset.voltCustom = "value";
382
383 registerPlugin("custom", (context: PluginContext) => {
384 context.lifecycle.onUnmount(() => {
385 onUnmountSpy();
386 });
387 });
388
389 const cleanup = mount(root, {});
390 cleanup();
391
392 expect(onUnmountSpy).toHaveBeenCalled();
393 });
394
395 it("plugin beforeBinding hooks execute before plugin handler", () => {
396 const executionOrder: string[] = [];
397 const root = document.createElement("div");
398 root.dataset.voltCustom = "value";
399
400 registerPlugin("custom", (context: PluginContext) => {
401 context.lifecycle.beforeBinding(() => {
402 executionOrder.push("beforeBinding");
403 });
404 executionOrder.push("handler");
405 });
406
407 mount(root, {});
408
409 expect(executionOrder).toEqual(["beforeBinding", "handler"]);
410 });
411
412 it("plugin afterBinding hooks execute after plugin handler", async () => {
413 const executionOrder: string[] = [];
414 const root = document.createElement("div");
415 root.dataset.voltCustom = "value";
416
417 registerPlugin("custom", (context: PluginContext) => {
418 executionOrder.push("handler");
419 context.lifecycle.afterBinding(() => {
420 executionOrder.push("afterBinding");
421 });
422 });
423
424 mount(root, {});
425
426 await new Promise((resolve) => setTimeout(resolve, 0));
427
428 expect(executionOrder).toEqual(["handler", "afterBinding"]);
429 });
430 });
431
432 describe("hook execution order", () => {
433 it("executes hooks in correct order during mount", () => {
434 const executionOrder: string[] = [];
435 const root = document.createElement("div");
436 root.innerHTML = "<div data-volt-text=\"message\"></div>";
437
438 registerGlobalHook("beforeMount", () => {
439 executionOrder.push("global:beforeMount");
440 });
441
442 registerGlobalHook("afterMount", () => {
443 executionOrder.push("global:afterMount");
444 });
445
446 const message = signal("test");
447 mount(root, { message });
448
449 expect(executionOrder).toEqual(["global:beforeMount", "global:afterMount"]);
450 });
451
452 it("executes hooks in correct order during unmount", () => {
453 const executionOrder: string[] = [];
454 const root = document.createElement("div");
455
456 registerGlobalHook("beforeUnmount", () => {
457 executionOrder.push("global:beforeUnmount");
458 });
459
460 registerGlobalHook("afterUnmount", () => {
461 executionOrder.push("global:afterUnmount");
462 });
463
464 const cleanup = mount(root, {});
465 cleanup();
466
467 expect(executionOrder).toEqual(["global:beforeUnmount", "global:afterUnmount"]);
468 });
469
470 it("executes mount and unmount in order", () => {
471 const executionOrder: string[] = [];
472 const root = document.createElement("div");
473
474 registerGlobalHook("beforeMount", () => {
475 executionOrder.push("beforeMount");
476 });
477
478 registerGlobalHook("afterMount", () => {
479 executionOrder.push("afterMount");
480 });
481
482 registerGlobalHook("beforeUnmount", () => {
483 executionOrder.push("beforeUnmount");
484 });
485
486 registerGlobalHook("afterUnmount", () => {
487 executionOrder.push("afterUnmount");
488 });
489
490 const cleanup = mount(root, {});
491 cleanup();
492
493 expect(executionOrder).toEqual(["beforeMount", "afterMount", "beforeUnmount", "afterUnmount"]);
494 });
495 });
496});