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