@cplieger/reactive 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,692 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // RED-TEAM round-5 — final convergence attack
3
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
- import {
5
- signal,
6
- effect,
7
- batch,
8
- computed,
9
- setEffectErrorHandler,
10
- flushSync,
11
- createStore,
12
- } from "./index.js";
13
- import type { EffectErrorHandler } from "./index.js";
14
-
15
- // ---------------------------------------------------------------------------
16
- // 1. Every effect in a batch has a cleanup that throws
17
- // ---------------------------------------------------------------------------
18
- describe("batch: cleanup throws on EVERY effect", () => {
19
- let prevHandler: EffectErrorHandler;
20
- const errors: unknown[] = [];
21
- beforeEach(() => {
22
- errors.length = 0;
23
- prevHandler = setEffectErrorHandler((e) => {
24
- errors.push(e);
25
- });
26
- });
27
- afterEach(() => {
28
- setEffectErrorHandler(prevHandler);
29
- });
30
-
31
- it("20 effects all with throwing cleanups still all re-execute in batch", () => {
32
- const s = signal(0);
33
- const spies = Array.from({ length: 20 }, () => vi.fn());
34
- for (let i = 0; i < 20; i++) {
35
- const spy = spies[i]!;
36
- effect(() => {
37
- spy(s.value);
38
- return () => {
39
- throw new Error(`batch-cleanup-${i}`);
40
- };
41
- });
42
- }
43
- for (const spy of spies) {
44
- spy.mockClear();
45
- }
46
- errors.length = 0;
47
- batch(() => {
48
- s.value = 42;
49
- });
50
- for (const spy of spies) {
51
- expect(spy).toHaveBeenCalledWith(42);
52
- }
53
- expect(errors.length).toBe(20);
54
- });
55
-
56
- it("nested batch: inner and outer effects' cleanups all throw", () => {
57
- const a = signal(0);
58
- const b = signal(0);
59
- const log: string[] = [];
60
- effect(() => {
61
- log.push(`a:${a.value}`);
62
- return () => {
63
- throw new Error("clean-a");
64
- };
65
- });
66
- effect(() => {
67
- log.push(`b:${b.value}`);
68
- return () => {
69
- throw new Error("clean-b");
70
- };
71
- });
72
- log.length = 0;
73
- errors.length = 0;
74
- batch(() => {
75
- a.value = 1;
76
- batch(() => {
77
- b.value = 1;
78
- });
79
- });
80
- expect(log).toContain("a:1");
81
- expect(log).toContain("b:1");
82
- expect(errors.length).toBe(2);
83
- });
84
- });
85
-
86
- // ---------------------------------------------------------------------------
87
- // 2. Error thrown inside effectErrorHandler
88
- // ---------------------------------------------------------------------------
89
- describe("effectErrorHandler itself throws", () => {
90
- let prevHandler: EffectErrorHandler;
91
- beforeEach(() => {
92
- prevHandler = setEffectErrorHandler(() => {
93
- /* swallow */
94
- });
95
- });
96
- afterEach(() => {
97
- setEffectErrorHandler(prevHandler);
98
- });
99
-
100
- it("handler throw during batch doesn't prevent other effects from running", () => {
101
- let _handlerCalls = 0;
102
- setEffectErrorHandler(() => {
103
- _handlerCalls++;
104
- throw new Error("handler-explodes");
105
- });
106
- const s = signal(0);
107
- const spy1 = vi.fn();
108
- const spy2 = vi.fn();
109
- effect(() => {
110
- spy1(s.value);
111
- if (s.value > 0) {
112
- throw new Error("e1");
113
- }
114
- return undefined;
115
- });
116
- effect(() => {
117
- spy2(s.value);
118
- return undefined;
119
- });
120
- spy1.mockClear();
121
- spy2.mockClear();
122
- // The handler throwing should not make the system unusable
123
- try {
124
- batch(() => {
125
- s.value = 1;
126
- });
127
- } catch {
128
- /* swallow top-level */
129
- }
130
- // After resetting the handler, the system should work
131
- setEffectErrorHandler(() => {
132
- /* swallow */
133
- });
134
- spy1.mockClear();
135
- spy2.mockClear();
136
- s.value = 2;
137
- expect(spy1).toHaveBeenCalledWith(2);
138
- expect(spy2).toHaveBeenCalledWith(2);
139
- });
140
-
141
- it("handler throw during cleanup: effect still re-subscribes on next trigger", () => {
142
- setEffectErrorHandler(() => {
143
- throw new Error("handler-ka-boom");
144
- });
145
- const s = signal(0);
146
- const spy = vi.fn();
147
- effect(() => {
148
- spy(s.value);
149
- return () => {
150
- throw new Error("cleanup!");
151
- };
152
- });
153
- spy.mockClear();
154
- try {
155
- s.value = 1;
156
- } catch {
157
- /* handler throws propagate */
158
- }
159
- // Reset handler and verify effect is alive
160
- setEffectErrorHandler(() => {
161
- /* swallow */
162
- });
163
- spy.mockClear();
164
- s.value = 2;
165
- expect(spy).toHaveBeenCalledWith(2);
166
- });
167
- });
168
-
169
- // ---------------------------------------------------------------------------
170
- // 3. Dispose during flush
171
- // ---------------------------------------------------------------------------
172
- describe("dispose during flush", () => {
173
- let prevHandler: EffectErrorHandler;
174
- const errors: unknown[] = [];
175
- beforeEach(() => {
176
- errors.length = 0;
177
- prevHandler = setEffectErrorHandler((e) => {
178
- errors.push(e);
179
- });
180
- });
181
- afterEach(() => {
182
- setEffectErrorHandler(prevHandler);
183
- });
184
-
185
- it("effect disposes itself during execution", () => {
186
- const s = signal(0);
187
- const log: number[] = [];
188
- const ref: { dispose?: () => void } = {};
189
- ref.dispose = effect(() => {
190
- const v = s.value;
191
- log.push(v);
192
- if (v === 1) {
193
- ref.dispose?.();
194
- }
195
- return undefined;
196
- });
197
- s.value = 1;
198
- s.value = 2;
199
- // After self-dispose at v=1, effect should not run for v=2
200
- expect(log).toEqual([0, 1]);
201
- });
202
-
203
- it("effect A disposes effect B during flush", () => {
204
- const s = signal(0);
205
- const logA: number[] = [];
206
- const logB: number[] = [];
207
- const refB: { dispose?: () => void } = {};
208
- effect(() => {
209
- const v = s.value;
210
- logA.push(v);
211
- if (v === 1) {
212
- refB.dispose?.();
213
- }
214
- return undefined;
215
- });
216
- refB.dispose = effect(() => {
217
- logB.push(s.value);
218
- return undefined;
219
- });
220
- s.value = 1;
221
- s.value = 2;
222
- // B should not run after being disposed by A during v=1 flush
223
- expect(logA).toContain(2);
224
- expect(logB).not.toContain(2);
225
- });
226
-
227
- it("dispose during flush with throwing cleanup", () => {
228
- const s = signal(0);
229
- const log: number[] = [];
230
- const ref: { dispose?: () => void } = {};
231
- ref.dispose = effect(() => {
232
- const v = s.value;
233
- log.push(v);
234
- if (v === 1) {
235
- ref.dispose?.();
236
- }
237
- return () => {
238
- throw new Error("self-dispose-cleanup");
239
- };
240
- });
241
- s.value = 1;
242
- // Should not crash; cleanup error goes to handler
243
- expect(log).toEqual([0, 1]);
244
- });
245
- });
246
-
247
- // ---------------------------------------------------------------------------
248
- // 4. Computed whose equals throws during diamond update
249
- // ---------------------------------------------------------------------------
250
- describe("computed equals throws during diamond update", () => {
251
- let prevHandler: EffectErrorHandler;
252
- const errors: unknown[] = [];
253
- beforeEach(() => {
254
- errors.length = 0;
255
- prevHandler = setEffectErrorHandler((e) => {
256
- errors.push(e);
257
- });
258
- });
259
- afterEach(() => {
260
- setEffectErrorHandler(prevHandler);
261
- });
262
-
263
- it("equals throwing treated as changed — effect re-runs", () => {
264
- const s = signal(0);
265
- let throwOnEquals = false;
266
- const c = computed(() => s.value * 2, {
267
- equals: () => {
268
- if (throwOnEquals) {
269
- throw new Error("equals-boom");
270
- }
271
- return false; // always changed
272
- },
273
- });
274
- const log: number[] = [];
275
- effect(() => {
276
- try {
277
- log.push(c.value);
278
- } catch {
279
- /* computed may throw */
280
- }
281
- return undefined;
282
- });
283
- throwOnEquals = true;
284
- s.value = 1;
285
- // equals threw → treated as changed → effect should have re-run
286
- expect(log.length).toBeGreaterThan(1);
287
- });
288
-
289
- it("diamond: equals throws on one branch, effect sees correct value", () => {
290
- const root = signal(0);
291
- let _throwCount = 0;
292
- const left = computed(() => root.value + 1, {
293
- equals: () => {
294
- _throwCount++;
295
- throw new Error("left-equals");
296
- },
297
- });
298
- const right = computed(() => root.value + 2);
299
- const log: string[] = [];
300
- effect(() => {
301
- try {
302
- log.push(`L=${left.value},R=${right.value}`);
303
- } catch (e) {
304
- log.push(`err:${(e as Error).message}`);
305
- }
306
- return undefined;
307
- });
308
- root.value = 10;
309
- // The effect should have re-run. Right branch should have correct value.
310
- expect(log.some((s) => s.includes("R=12"))).toBe(true);
311
- });
312
- });
313
-
314
- // ---------------------------------------------------------------------------
315
- // 5. Store effect cleanup throw (verify round-4 fix)
316
- // ---------------------------------------------------------------------------
317
- describe("store effect cleanup throw (round-4 verification)", () => {
318
- it("store effect cleanup throw: effect re-subscribes to changed keys", () => {
319
- const store = createStore<{ x: number; y: number }>();
320
- store.set("x", 0);
321
- store.set("y", 0);
322
- const log: string[] = [];
323
- const origErr = console.error;
324
- console.error = vi.fn();
325
- store.effect(() => {
326
- log.push(`x=${store.get("x")},y=${store.get("y")}`);
327
- return () => {
328
- throw new Error("store-cleanup");
329
- };
330
- });
331
- store.set("x", 1);
332
- store.set("y", 2);
333
- console.error = origErr;
334
- expect(log).toContain("x=0,y=0");
335
- expect(log).toContain("x=1,y=0");
336
- expect(log).toContain("x=1,y=2");
337
- });
338
-
339
- it("store batch + cleanup throws on all effects", () => {
340
- const store = createStore<{ n: number }>();
341
- store.set("n", 0);
342
- const spies = Array.from({ length: 5 }, () => vi.fn());
343
- const origErr = console.error;
344
- console.error = vi.fn();
345
- for (const spy of spies) {
346
- store.effect(() => {
347
- spy(store.get("n"));
348
- return () => {
349
- throw new Error("store-batch-cleanup");
350
- };
351
- });
352
- }
353
- for (const spy of spies) {
354
- spy.mockClear();
355
- }
356
- store.batch(() => {
357
- store.set("n", 99);
358
- });
359
- console.error = origErr;
360
- for (const spy of spies) {
361
- expect(spy).toHaveBeenCalledWith(99);
362
- }
363
- });
364
- });
365
-
366
- // ---------------------------------------------------------------------------
367
- // 6. Deep dispose chains
368
- // ---------------------------------------------------------------------------
369
- describe("deep dispose chains", () => {
370
- let prevHandler: EffectErrorHandler;
371
- const errors: unknown[] = [];
372
- beforeEach(() => {
373
- errors.length = 0;
374
- prevHandler = setEffectErrorHandler((e) => {
375
- errors.push(e);
376
- });
377
- });
378
- afterEach(() => {
379
- setEffectErrorHandler(prevHandler);
380
- });
381
-
382
- it("effect creates child effects; disposing parent doesn't crash", () => {
383
- const s = signal(0);
384
- const childDisposers: (() => void)[] = [];
385
- const parentDispose = effect(() => {
386
- const v = s.value;
387
- // Create child effects each time parent runs
388
- const d = effect(() => {
389
- void s.value; // track
390
- return () => {
391
- throw new Error(`child-cleanup-${v}`);
392
- };
393
- });
394
- childDisposers.push(d);
395
- return () => {
396
- // Parent cleanup disposes children
397
- for (const cd of childDisposers) {
398
- cd();
399
- }
400
- childDisposers.length = 0;
401
- };
402
- });
403
- s.value = 1;
404
- s.value = 2;
405
- // Dispose parent — should not throw
406
- expect(() => parentDispose()).not.toThrow();
407
- });
408
-
409
- it("100-deep dispose chain with throwing cleanups", () => {
410
- const s = signal(0);
411
- const disposers: (() => void)[] = [];
412
- for (let i = 0; i < 100; i++) {
413
- disposers.push(
414
- effect(() => {
415
- void s.value;
416
- return () => {
417
- throw new Error(`deep-${i}`);
418
- };
419
- }),
420
- );
421
- }
422
- errors.length = 0;
423
- // Dispose all — no crashes, all errors routed to handler
424
- for (const d of disposers) {
425
- d();
426
- }
427
- expect(errors.length).toBe(100);
428
- // Signal should still work after mass dispose
429
- const spy = vi.fn();
430
- const d2 = effect(() => {
431
- spy(s.value);
432
- return undefined;
433
- });
434
- spy.mockClear();
435
- s.value = 999;
436
- expect(spy).toHaveBeenCalledWith(999);
437
- d2();
438
- });
439
- });
440
-
441
- // ---------------------------------------------------------------------------
442
- // 7. Leak check after mass dispose
443
- // ---------------------------------------------------------------------------
444
- describe("leak check after mass dispose", () => {
445
- let prevHandler: EffectErrorHandler;
446
- beforeEach(() => {
447
- prevHandler = setEffectErrorHandler(() => {
448
- /* swallow */
449
- });
450
- });
451
- afterEach(() => {
452
- setEffectErrorHandler(prevHandler);
453
- });
454
-
455
- it("disposed effects are not triggered by signal changes", () => {
456
- const s = signal(0);
457
- const disposers: (() => void)[] = [];
458
- const spies: ReturnType<typeof vi.fn>[] = [];
459
- for (let i = 0; i < 50; i++) {
460
- const spy = vi.fn();
461
- spies.push(spy);
462
- disposers.push(
463
- effect(() => {
464
- spy(s.value);
465
- return () => {
466
- throw new Error(`leak-cleanup-${i}`);
467
- };
468
- }),
469
- );
470
- }
471
- // Dispose all
472
- for (const d of disposers) {
473
- d();
474
- }
475
- // Clear spies
476
- for (const spy of spies) {
477
- spy.mockClear();
478
- }
479
- // Change signal — no disposed effects should fire
480
- s.value = 100;
481
- for (const spy of spies) {
482
- expect(spy).not.toHaveBeenCalled();
483
- }
484
- });
485
-
486
- it("store: disposed effects don't leak subscriptions", () => {
487
- const store = createStore<{ k: number }>();
488
- store.set("k", 0);
489
- const disposers: (() => void)[] = [];
490
- const spies: ReturnType<typeof vi.fn>[] = [];
491
- const origErr = console.error;
492
- console.error = vi.fn();
493
- for (let i = 0; i < 30; i++) {
494
- const spy = vi.fn();
495
- spies.push(spy);
496
- disposers.push(
497
- store.effect(() => {
498
- spy(store.get("k"));
499
- return () => {
500
- throw new Error(`store-leak-${i}`);
501
- };
502
- }),
503
- );
504
- }
505
- for (const d of disposers) {
506
- d();
507
- }
508
- for (const spy of spies) {
509
- spy.mockClear();
510
- }
511
- store.set("k", 999);
512
- console.error = origErr;
513
- for (const spy of spies) {
514
- expect(spy).not.toHaveBeenCalled();
515
- }
516
- });
517
-
518
- it("computed with disposed subscribers doesn't accumulate dead refs", () => {
519
- const s = signal(0);
520
- const c = computed(() => s.value * 3);
521
- const disposers: (() => void)[] = [];
522
- for (let i = 0; i < 50; i++) {
523
- disposers.push(
524
- effect(() => {
525
- void c.value;
526
- return undefined;
527
- }),
528
- );
529
- }
530
- for (const d of disposers) {
531
- d();
532
- }
533
- // After disposing, computed should still work correctly
534
- s.value = 7;
535
- expect(c.value).toBe(21);
536
- // New effect on computed should work fine
537
- const spy = vi.fn();
538
- const d = effect(() => {
539
- spy(c.value);
540
- return undefined;
541
- });
542
- spy.mockClear();
543
- s.value = 8;
544
- expect(spy).toHaveBeenCalledWith(24);
545
- d();
546
- });
547
- });
548
-
549
- // ---------------------------------------------------------------------------
550
- // 8. Dispose re-entrancy guard (round-4 fix verification)
551
- // ---------------------------------------------------------------------------
552
- describe("dispose re-entrancy guard", () => {
553
- let prevHandler: EffectErrorHandler;
554
- const errors: unknown[] = [];
555
- beforeEach(() => {
556
- errors.length = 0;
557
- prevHandler = setEffectErrorHandler((e) => {
558
- errors.push(e);
559
- });
560
- });
561
- afterEach(() => {
562
- setEffectErrorHandler(prevHandler);
563
- });
564
-
565
- it("calling dispose multiple times is safe", () => {
566
- const s = signal(0);
567
- const spy = vi.fn();
568
- const dispose = effect(() => {
569
- spy(s.value);
570
- return () => {
571
- throw new Error("multi-dispose");
572
- };
573
- });
574
- dispose();
575
- dispose();
576
- dispose();
577
- // Only one cleanup error, not three
578
- expect(errors.length).toBe(1);
579
- });
580
-
581
- it("dispose inside effect body (re-entrancy) is safe", () => {
582
- const s = signal(0);
583
- const ref: { dispose?: () => void } = {};
584
- const log: number[] = [];
585
- ref.dispose = effect(() => {
586
- const v = s.value;
587
- log.push(v);
588
- if (v === 5) {
589
- ref.dispose?.();
590
- }
591
- return undefined;
592
- });
593
- s.value = 5;
594
- s.value = 6;
595
- expect(log).toEqual([0, 5]);
596
- });
597
- });
598
-
599
- // ---------------------------------------------------------------------------
600
- // 9. flushSync interaction with error paths
601
- // ---------------------------------------------------------------------------
602
- describe("flushSync with errors", () => {
603
- let prevHandler: EffectErrorHandler;
604
- const errors: unknown[] = [];
605
- beforeEach(() => {
606
- errors.length = 0;
607
- prevHandler = setEffectErrorHandler((e) => {
608
- errors.push(e);
609
- });
610
- });
611
- afterEach(() => {
612
- setEffectErrorHandler(prevHandler);
613
- });
614
-
615
- it("flushSync after batch with throwing effects", () => {
616
- const s = signal(0);
617
- const spy = vi.fn();
618
- effect(() => {
619
- spy(s.value);
620
- return () => {
621
- throw new Error("flush-cleanup");
622
- };
623
- });
624
- spy.mockClear();
625
- batch(() => {
626
- s.value = 1;
627
- // flushSync inside batch is a no-op
628
- flushSync();
629
- expect(spy).not.toHaveBeenCalled();
630
- });
631
- // After batch, effect should have run
632
- expect(spy).toHaveBeenCalledWith(1);
633
- });
634
- });
635
-
636
- // ---------------------------------------------------------------------------
637
- // 10. Stress: rapid signal changes with computed + effect + cleanup throws
638
- // ---------------------------------------------------------------------------
639
- describe("stress: rapid changes with computed + effect + cleanup throws", () => {
640
- let prevHandler: EffectErrorHandler;
641
- const errors: unknown[] = [];
642
- beforeEach(() => {
643
- errors.length = 0;
644
- prevHandler = setEffectErrorHandler((e) => {
645
- errors.push(e);
646
- });
647
- });
648
- afterEach(() => {
649
- setEffectErrorHandler(prevHandler);
650
- });
651
-
652
- it("1000 rapid writes with cleanup-throwing effect", () => {
653
- const s = signal(0);
654
- let lastSeen = -1;
655
- effect(() => {
656
- lastSeen = s.value;
657
- return () => {
658
- throw new Error("rapid-cleanup");
659
- };
660
- });
661
- errors.length = 0;
662
- for (let i = 1; i <= 1000; i++) {
663
- s.value = i;
664
- }
665
- expect(lastSeen).toBe(1000);
666
- expect(errors.length).toBe(1000);
667
- });
668
-
669
- it("1000 rapid writes in batch: single flush", () => {
670
- const s = signal(0);
671
- let lastSeen = -1;
672
- let runCount = 0;
673
- effect(() => {
674
- lastSeen = s.value;
675
- runCount++;
676
- return () => {
677
- throw new Error("batch-rapid-cleanup");
678
- };
679
- });
680
- runCount = 0;
681
- errors.length = 0;
682
- batch(() => {
683
- for (let i = 1; i <= 1000; i++) {
684
- s.value = i;
685
- }
686
- });
687
- expect(lastSeen).toBe(1000);
688
- // Should only run once in batch (single flush)
689
- expect(runCount).toBe(1);
690
- expect(errors.length).toBe(1); // one cleanup from the initial run
691
- });
692
- });