a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 16 kB view raw
1import { asyncEffect } from "$core/async-effect"; 2import { signal } from "$core/signal"; 3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 5describe("asyncEffect", () => { 6 beforeEach(() => { 7 vi.useFakeTimers(); 8 }); 9 10 afterEach(() => { 11 vi.restoreAllMocks(); 12 vi.useRealTimers(); 13 }); 14 15 describe("basic async execution", () => { 16 it("executes async effect immediately", async () => { 17 const spy = vi.fn(); 18 const dependency = signal(0); 19 20 asyncEffect(async () => { 21 spy(); 22 }, [dependency]); 23 24 await vi.runAllTimersAsync(); 25 26 expect(spy).toHaveBeenCalledTimes(1); 27 }); 28 29 it("executes when dependency changes", async () => { 30 const spy = vi.fn(); 31 const dependency = signal(0); 32 33 asyncEffect(async () => { 34 spy(dependency.get()); 35 }, [dependency]); 36 37 await vi.runAllTimersAsync(); 38 expect(spy).toHaveBeenCalledWith(0); 39 40 dependency.set(1); 41 await vi.runAllTimersAsync(); 42 expect(spy).toHaveBeenCalledWith(1); 43 expect(spy).toHaveBeenCalledTimes(2); 44 }); 45 46 it("supports multiple dependencies", async () => { 47 const spy = vi.fn(); 48 const dep1 = signal(1); 49 const dep2 = signal(2); 50 51 asyncEffect(async () => { 52 spy(dep1.get(), dep2.get()); 53 }, [dep1, dep2]); 54 55 await vi.runAllTimersAsync(); 56 expect(spy).toHaveBeenCalledWith(1, 2); 57 58 dep1.set(10); 59 await vi.runAllTimersAsync(); 60 expect(spy).toHaveBeenCalledWith(10, 2); 61 62 dep2.set(20); 63 await vi.runAllTimersAsync(); 64 expect(spy).toHaveBeenCalledWith(10, 20); 65 66 expect(spy).toHaveBeenCalledTimes(3); 67 }); 68 69 it("handles cleanup functions", async () => { 70 const cleanupSpy = vi.fn(); 71 const dependency = signal(0); 72 73 asyncEffect(async () => { 74 return () => { 75 cleanupSpy(); 76 }; 77 }, [dependency]); 78 79 await vi.runAllTimersAsync(); 80 81 dependency.set(1); 82 await vi.runAllTimersAsync(); 83 84 expect(cleanupSpy).toHaveBeenCalledTimes(1); 85 }); 86 87 it("calls cleanup on unmount", async () => { 88 const cleanupSpy = vi.fn(); 89 const dependency = signal(0); 90 91 const unsubscribe = asyncEffect(async () => { 92 return () => { 93 cleanupSpy(); 94 }; 95 }, [dependency]); 96 97 await vi.runAllTimersAsync(); 98 99 unsubscribe(); 100 await vi.runAllTimersAsync(); 101 102 expect(cleanupSpy).toHaveBeenCalledTimes(1); 103 }); 104 }); 105 106 describe("abort controller integration", () => { 107 it("provides AbortSignal when abortable option is true", async () => { 108 let receivedSignal: AbortSignal | undefined; 109 const dependency = signal(0); 110 111 asyncEffect( 112 async (signal) => { 113 receivedSignal = signal; 114 }, 115 [dependency], 116 { abortable: true }, 117 ); 118 119 await vi.runAllTimersAsync(); 120 121 expect(receivedSignal).toBeInstanceOf(AbortSignal); 122 expect(receivedSignal?.aborted).toBe(false); 123 }); 124 125 it("aborts previous effect when dependency changes", async () => { 126 const signals: AbortSignal[] = []; 127 const dependency = signal(0); 128 129 asyncEffect( 130 async (signal) => { 131 if (signal) { 132 signals.push(signal); 133 } 134 await new Promise((resolve) => setTimeout(resolve, 100)); 135 }, 136 [dependency], 137 { abortable: true }, 138 ); 139 140 await vi.advanceTimersByTimeAsync(50); 141 142 dependency.set(1); 143 await vi.advanceTimersByTimeAsync(50); 144 145 expect(signals).toHaveLength(2); 146 expect(signals[0].aborted).toBe(true); 147 expect(signals[1].aborted).toBe(false); 148 }); 149 150 it("aborts on cleanup", async () => { 151 let abortSignal: AbortSignal | undefined; 152 const dependency = signal(0); 153 154 const cleanup = asyncEffect( 155 async (signal) => { 156 abortSignal = signal; 157 }, 158 [dependency], 159 { abortable: true }, 160 ); 161 162 await vi.runAllTimersAsync(); 163 164 cleanup(); 165 166 expect(abortSignal?.aborted).toBe(true); 167 }); 168 169 it("does not provide signal when abortable is false", async () => { 170 const signals: (AbortSignal | undefined)[] = []; 171 const dependency = signal(0); 172 173 asyncEffect( 174 async (signal) => { 175 signals.push(signal); 176 }, 177 [dependency], 178 { abortable: false }, 179 ); 180 181 await vi.runAllTimersAsync(); 182 183 expect(signals).toHaveLength(1); 184 expect(signals[0]).toBeUndefined(); 185 }); 186 }); 187 188 describe("race protection", () => { 189 it("discards results from stale executions via execution ID check", async () => { 190 const results: number[] = []; 191 const dependency = signal(0); 192 let currentExecutionId = 0; 193 194 asyncEffect(async () => { 195 const executionId = ++currentExecutionId; 196 const value = dependency.get(); 197 const delay = value === 0 ? 100 : 10; 198 await new Promise((resolve) => setTimeout(resolve, delay)); 199 200 if (executionId === currentExecutionId) { 201 results.push(value); 202 } 203 }, [dependency]); 204 205 await vi.advanceTimersByTimeAsync(50); 206 207 dependency.set(1); 208 209 await vi.runAllTimersAsync(); 210 211 expect(results.at(-1)).toBe(1); 212 }); 213 214 it("tracks execution order with race conditions", async () => { 215 const startTimes: number[] = []; 216 const completionTimes: number[] = []; 217 const dependency = signal(0); 218 219 asyncEffect(async () => { 220 const value = dependency.get(); 221 startTimes.push(value); 222 await new Promise((resolve) => setTimeout(resolve, 50)); 223 completionTimes.push(value); 224 }, [dependency]); 225 226 await vi.advanceTimersByTimeAsync(10); 227 dependency.set(1); 228 229 await vi.advanceTimersByTimeAsync(10); 230 dependency.set(2); 231 232 await vi.runAllTimersAsync(); 233 234 expect(startTimes.length).toBeGreaterThanOrEqual(1); 235 }); 236 }); 237 238 describe("debounce", () => { 239 it("delays execution until debounce period passes", async () => { 240 const spy = vi.fn(); 241 const dependency = signal(0); 242 243 asyncEffect( 244 async () => { 245 spy(dependency.get()); 246 }, 247 [dependency], 248 { debounce: 300 }, 249 ); 250 251 expect(spy).not.toHaveBeenCalled(); 252 253 await vi.advanceTimersByTimeAsync(200); 254 expect(spy).not.toHaveBeenCalled(); 255 256 await vi.advanceTimersByTimeAsync(100); 257 expect(spy).toHaveBeenCalledWith(0); 258 expect(spy).toHaveBeenCalledTimes(1); 259 }); 260 261 it("resets debounce timer on each dependency change", async () => { 262 const spy = vi.fn(); 263 const dependency = signal(0); 264 265 asyncEffect( 266 async () => { 267 spy(dependency.get()); 268 }, 269 [dependency], 270 { debounce: 300 }, 271 ); 272 273 await vi.advanceTimersByTimeAsync(200); 274 dependency.set(1); 275 276 await vi.advanceTimersByTimeAsync(200); 277 dependency.set(2); 278 279 await vi.advanceTimersByTimeAsync(200); 280 expect(spy).not.toHaveBeenCalled(); 281 282 await vi.advanceTimersByTimeAsync(100); 283 expect(spy).toHaveBeenCalledWith(2); 284 expect(spy).toHaveBeenCalledTimes(1); 285 }); 286 287 it("executes only once after multiple rapid changes", async () => { 288 const spy = vi.fn(); 289 const dependency = signal(0); 290 291 asyncEffect( 292 async () => { 293 spy(dependency.get()); 294 }, 295 [dependency], 296 { debounce: 100 }, 297 ); 298 299 for (let i = 1; i <= 5; i++) { 300 dependency.set(i); 301 await vi.advanceTimersByTimeAsync(50); 302 } 303 304 await vi.runAllTimersAsync(); 305 306 expect(spy).toHaveBeenCalledWith(5); 307 expect(spy).toHaveBeenCalledTimes(1); 308 }); 309 }); 310 311 describe("throttle", () => { 312 it("limits execution frequency", async () => { 313 const spy = vi.fn(); 314 const dependency = signal(0); 315 316 asyncEffect( 317 async () => { 318 spy(dependency.get()); 319 }, 320 [dependency], 321 { throttle: 200 }, 322 ); 323 324 await vi.runAllTimersAsync(); 325 expect(spy).toHaveBeenCalledTimes(1); 326 327 dependency.set(1); 328 await vi.advanceTimersByTimeAsync(100); 329 expect(spy).toHaveBeenCalledTimes(1); 330 331 await vi.advanceTimersByTimeAsync(100); 332 expect(spy).toHaveBeenCalledTimes(2); 333 }); 334 335 it("executes immediately on first trigger", async () => { 336 const spy = vi.fn(); 337 const dependency = signal(0); 338 339 asyncEffect( 340 async () => { 341 spy(dependency.get()); 342 }, 343 [dependency], 344 { throttle: 1000 }, 345 ); 346 347 await vi.runAllTimersAsync(); 348 expect(spy).toHaveBeenCalledWith(0); 349 }); 350 351 it("queues one execution during throttle period", async () => { 352 const spy = vi.fn(); 353 const dependency = signal(0); 354 355 asyncEffect( 356 async () => { 357 spy(dependency.get()); 358 }, 359 [dependency], 360 { throttle: 300 }, 361 ); 362 363 await vi.runAllTimersAsync(); 364 expect(spy).toHaveBeenCalledTimes(1); 365 366 dependency.set(1); 367 dependency.set(2); 368 dependency.set(3); 369 370 await vi.advanceTimersByTimeAsync(300); 371 372 expect(spy).toHaveBeenCalledWith(3); 373 expect(spy).toHaveBeenCalledTimes(2); 374 }); 375 }); 376 377 describe("error handling", () => { 378 it("catches and logs errors", async () => { 379 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 380 const dependency = signal(0); 381 382 asyncEffect(async () => { 383 throw new Error("Test error"); 384 }, [dependency]); 385 386 await vi.runAllTimersAsync(); 387 388 expect(consoleErrorSpy).toHaveBeenCalledTimes(2); 389 expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[effect]")); 390 expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); 391 392 consoleErrorSpy.mockRestore(); 393 }); 394 395 it("calls onError handler", async () => { 396 const errorHandler = vi.fn(); 397 const dependency = signal(0); 398 399 asyncEffect( 400 async () => { 401 throw new Error("Test error"); 402 }, 403 [dependency], 404 { onError: errorHandler }, 405 ); 406 407 await vi.runAllTimersAsync(); 408 409 expect(errorHandler).toHaveBeenCalledWith(expect.any(Error), expect.any(Function)); 410 }); 411 412 it("retries on error when retries option is set", async () => { 413 let attempts = 0; 414 const dependency = signal(0); 415 416 asyncEffect( 417 async () => { 418 attempts++; 419 if (attempts < 3) { 420 throw new Error("Retry test"); 421 } 422 }, 423 [dependency], 424 { retries: 3 }, 425 ); 426 427 await vi.runAllTimersAsync(); 428 429 expect(attempts).toBe(3); 430 }); 431 432 it("respects retry delay", async () => { 433 let attempts = 0; 434 const dependency = signal(0); 435 436 asyncEffect( 437 async () => { 438 attempts++; 439 if (attempts < 2) { 440 throw new Error("Retry test"); 441 } 442 }, 443 [dependency], 444 { retries: 2, retryDelay: 500 }, 445 ); 446 447 await vi.advanceTimersByTimeAsync(100); 448 expect(attempts).toBe(1); 449 450 await vi.advanceTimersByTimeAsync(500); 451 expect(attempts).toBe(2); 452 }); 453 454 it("allows manual retry via onError callback", async () => { 455 let attempts = 0; 456 const dependency = signal(0); 457 458 asyncEffect( 459 async () => { 460 attempts++; 461 if (attempts <= 2) { 462 throw new Error("Retry test"); 463 } 464 }, 465 [dependency], 466 { 467 retries: 1, 468 onError: (_error, retry) => { 469 if (attempts === 2) { 470 retry(); 471 } 472 }, 473 }, 474 ); 475 476 await vi.runAllTimersAsync(); 477 478 expect(attempts).toBe(3); 479 }); 480 481 it("does not retry aborted operations", async () => { 482 let attempts = 0; 483 const dependency = signal(0); 484 485 asyncEffect( 486 async (signal) => { 487 attempts++; 488 signal?.addEventListener("abort", () => { 489 throw new Error("Aborted"); 490 }); 491 await new Promise((resolve) => setTimeout(resolve, 100)); 492 signal?.dispatchEvent(new Event("abort")); 493 }, 494 [dependency], 495 { abortable: true, retries: 3 }, 496 ); 497 498 await vi.advanceTimersByTimeAsync(50); 499 dependency.set(1); 500 await vi.runAllTimersAsync(); 501 502 expect(attempts).toBe(2); 503 }); 504 }); 505 506 describe("cleanup behavior", () => { 507 it("cleans up debounce timers on unmount", async () => { 508 const spy = vi.fn(); 509 const dependency = signal(0); 510 511 const cleanup = asyncEffect( 512 async () => { 513 spy(); 514 }, 515 [dependency], 516 { debounce: 1000 }, 517 ); 518 519 await vi.advanceTimersByTimeAsync(500); 520 cleanup(); 521 await vi.runAllTimersAsync(); 522 523 expect(spy).not.toHaveBeenCalled(); 524 }); 525 526 it("cleans up throttle timers on unmount", async () => { 527 const spy = vi.fn(); 528 const dependency = signal(0); 529 530 const cleanup = asyncEffect( 531 async () => { 532 spy(); 533 }, 534 [dependency], 535 { throttle: 1000 }, 536 ); 537 538 await vi.runAllTimersAsync(); 539 expect(spy).toHaveBeenCalledTimes(1); 540 541 dependency.set(1); 542 await vi.advanceTimersByTimeAsync(500); 543 cleanup(); 544 await vi.runAllTimersAsync(); 545 546 expect(spy).toHaveBeenCalledTimes(1); 547 }); 548 549 it("unsubscribes from all dependencies on cleanup", async () => { 550 const spy = vi.fn(); 551 const dep1 = signal(0); 552 const dep2 = signal(0); 553 554 const cleanup = asyncEffect(async () => { 555 spy(); 556 }, [dep1, dep2]); 557 558 await vi.runAllTimersAsync(); 559 expect(spy).toHaveBeenCalledTimes(1); 560 561 cleanup(); 562 563 dep1.set(1); 564 dep2.set(1); 565 await vi.runAllTimersAsync(); 566 567 expect(spy).toHaveBeenCalledTimes(1); 568 }); 569 570 it("cleanup prevents new executions", async () => { 571 const executionCount = vi.fn(); 572 const dependency = signal(0); 573 574 const cleanup = asyncEffect(async () => { 575 executionCount(); 576 }, [dependency]); 577 578 await vi.runAllTimersAsync(); 579 const countAfterFirstRun = executionCount.mock.calls.length; 580 581 cleanup(); 582 583 dependency.set(1); 584 await vi.runAllTimersAsync(); 585 586 expect(executionCount).toHaveBeenCalledTimes(countAfterFirstRun); 587 }); 588 }); 589 590 describe("complex scenarios", () => { 591 it("combines debounce with abort", async () => { 592 const spy = vi.fn(); 593 const signals: AbortSignal[] = []; 594 const dependency = signal(0); 595 596 asyncEffect( 597 async (signal) => { 598 if (signal) { 599 signals.push(signal); 600 } 601 spy(dependency.get()); 602 }, 603 [dependency], 604 { debounce: 200, abortable: true }, 605 ); 606 607 dependency.set(1); 608 await vi.advanceTimersByTimeAsync(100); 609 610 dependency.set(2); 611 await vi.advanceTimersByTimeAsync(200); 612 613 expect(spy).toHaveBeenCalledWith(2); 614 expect(spy).toHaveBeenCalledTimes(1); 615 }); 616 617 it("combines throttle with error handling", async () => { 618 let attempts = 0; 619 const dependency = signal(0); 620 621 asyncEffect( 622 async () => { 623 attempts++; 624 if (attempts === 1) { 625 throw new Error("First attempt fails"); 626 } 627 }, 628 [dependency], 629 { throttle: 100, retries: 1 }, 630 ); 631 632 await vi.runAllTimersAsync(); 633 634 expect(attempts).toBe(2); 635 }); 636 637 it("handles rapid changes with all features enabled", async () => { 638 const results: number[] = []; 639 const dependency = signal(0); 640 641 asyncEffect( 642 async (signal) => { 643 const value = dependency.get(); 644 await new Promise((resolve) => setTimeout(resolve, 50)); 645 if (!signal?.aborted) { 646 results.push(value); 647 } 648 }, 649 [dependency], 650 { debounce: 100, abortable: true, retries: 1 }, 651 ); 652 653 for (let i = 1; i <= 5; i++) { 654 dependency.set(i); 655 await vi.advanceTimersByTimeAsync(50); 656 } 657 658 await vi.runAllTimersAsync(); 659 660 expect(results).toEqual([5]); 661 }); 662 }); 663});