a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 496 lines 15 kB view raw
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});