@cplieger/reactive 1.0.0 → 1.0.1

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.
@@ -0,0 +1,711 @@
1
+ // @vitest-environment happy-dom
2
+ // RED-TEAM round-2 adversarial tests
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import {
5
+ signal,
6
+ effect,
7
+ batch,
8
+ computed,
9
+ on,
10
+ setEffectErrorHandler,
11
+ flushSync,
12
+ createStore,
13
+ reconcile,
14
+ } from "./index.js";
15
+ import type { EffectErrorHandler } from "./index.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Verify round-1 fixes: drainPending try/finally + equals-throw
19
+ // ---------------------------------------------------------------------------
20
+
21
+ describe("round-1 fix verification: drainPending invariants", () => {
22
+ let prevHandler: EffectErrorHandler;
23
+ beforeEach(() => {
24
+ prevHandler = setEffectErrorHandler(() => {
25
+ /* swallow */
26
+ });
27
+ });
28
+ afterEach(() => {
29
+ setEffectErrorHandler(prevHandler);
30
+ });
31
+
32
+ it("flushing flag resets after effect throws", () => {
33
+ const s = signal(0);
34
+ let threw = false;
35
+ effect(() => {
36
+ if (s.value === 1 && !threw) {
37
+ threw = true;
38
+ throw new Error("effect boom");
39
+ }
40
+ return undefined;
41
+ });
42
+ s.value = 1; // triggers throw inside drainPending
43
+ // System should still work - flushing must be false
44
+ const spy = vi.fn();
45
+ effect(() => {
46
+ spy(s.value);
47
+ return undefined;
48
+ });
49
+ spy.mockClear();
50
+ s.value = 2;
51
+ expect(spy).toHaveBeenCalledWith(2);
52
+ });
53
+
54
+ it("batchDepth resets after nested batch throws at each level", () => {
55
+ const s = signal(0);
56
+ const spy = vi.fn();
57
+ effect(() => {
58
+ spy(s.value);
59
+ return undefined;
60
+ });
61
+ spy.mockClear();
62
+
63
+ // Nested throws at each level
64
+ try {
65
+ batch(() => {
66
+ s.value = 1;
67
+ try {
68
+ batch(() => {
69
+ s.value = 2;
70
+ try {
71
+ batch(() => {
72
+ s.value = 3;
73
+ throw new Error("level 3");
74
+ });
75
+ } catch {
76
+ /* swallow */
77
+ }
78
+ throw new Error("level 2");
79
+ });
80
+ } catch {
81
+ /* swallow */
82
+ }
83
+ throw new Error("level 1");
84
+ });
85
+ } catch {
86
+ /* swallow */
87
+ }
88
+
89
+ // batchDepth must be 0 - subsequent writes flush immediately
90
+ spy.mockClear();
91
+ s.value = 99;
92
+ expect(spy).toHaveBeenCalledWith(99);
93
+ });
94
+
95
+ it("equals-throw in signal does not corrupt flushing state", () => {
96
+ const s = signal(1, {
97
+ equals: (_prev, next) => {
98
+ if (next === 42) {
99
+ throw new Error("equals boom");
100
+ }
101
+ return false; // always notify
102
+ },
103
+ });
104
+ const spy = vi.fn();
105
+ effect(() => {
106
+ spy(s.value);
107
+ return undefined;
108
+ });
109
+ spy.mockClear();
110
+
111
+ expect(() => {
112
+ s.value = 42;
113
+ }).toThrow("equals boom");
114
+
115
+ // System should still work
116
+ s.value = 2;
117
+ expect(spy).toHaveBeenCalledWith(2);
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Exception in effect body (not equals)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("exception in effect body", () => {
126
+ let prevHandler: EffectErrorHandler;
127
+ const errors: unknown[] = [];
128
+ beforeEach(() => {
129
+ errors.length = 0;
130
+ prevHandler = setEffectErrorHandler((e) => {
131
+ errors.push(e);
132
+ });
133
+ });
134
+ afterEach(() => {
135
+ setEffectErrorHandler(prevHandler);
136
+ });
137
+
138
+ it("effect error does not prevent other pending effects from running", () => {
139
+ const s = signal(0);
140
+ const spy1 = vi.fn();
141
+ const spy2 = vi.fn();
142
+
143
+ // Effect 1 throws
144
+ effect(() => {
145
+ if (s.value === 1) {
146
+ throw new Error("e1 boom");
147
+ }
148
+ spy1(s.value);
149
+ return undefined;
150
+ });
151
+ // Effect 2 should still run
152
+ effect(() => {
153
+ spy2(s.value);
154
+ return undefined;
155
+ });
156
+
157
+ spy1.mockClear();
158
+ spy2.mockClear();
159
+ s.value = 1;
160
+ expect(spy2).toHaveBeenCalledWith(1);
161
+ expect(errors.length).toBe(1);
162
+ });
163
+
164
+ it("effect error during batch does not prevent flush of remaining effects", () => {
165
+ const a = signal(0);
166
+ const b = signal(0);
167
+ const spy = vi.fn();
168
+
169
+ effect(() => {
170
+ if (a.value === 1) {
171
+ throw new Error("a boom");
172
+ }
173
+ return undefined;
174
+ });
175
+ effect(() => {
176
+ spy(b.value);
177
+ return undefined;
178
+ });
179
+
180
+ spy.mockClear();
181
+ batch(() => {
182
+ a.value = 1;
183
+ b.value = 1;
184
+ });
185
+ expect(spy).toHaveBeenCalledWith(1);
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Effect that writes the signal it reads (self-trigger loop bound)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe("self-trigger loop bound", () => {
194
+ it("effect writing its own signal converges (bounded iterations)", () => {
195
+ const s = signal(0);
196
+ let runs = 0;
197
+ effect(() => {
198
+ runs++;
199
+ if (s.value < 100) {
200
+ s.value = s.value + 1;
201
+ }
202
+ return undefined;
203
+ });
204
+ // Should converge at 100, not infinite loop
205
+ expect(runs).toBe(101);
206
+ expect(s.peek()).toBe(100);
207
+ });
208
+
209
+ it("effect writing its own signal in batch converges", () => {
210
+ const s = signal(0);
211
+ let runs = 0;
212
+ effect(() => {
213
+ runs++;
214
+ const v = s.value;
215
+ if (v < 50) {
216
+ batch(() => {
217
+ s.value = v + 1;
218
+ });
219
+ }
220
+ return undefined;
221
+ });
222
+ expect(runs).toBe(51);
223
+ expect(s.peek()).toBe(50);
224
+ });
225
+ });
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Computed depending on computed depending on signal with throwing equals
229
+ // ---------------------------------------------------------------------------
230
+
231
+ describe("computed chain with throwing equals", () => {
232
+ it("computed → computed → signal, inner equals throws", () => {
233
+ const s = signal(1);
234
+ let throwOnEquals = false;
235
+ const inner = computed(() => s.value * 2, {
236
+ equals: () => {
237
+ if (throwOnEquals) {
238
+ throw new Error("inner eq boom");
239
+ }
240
+ return false; // always changed
241
+ },
242
+ });
243
+ const outer = computed(() => inner.value + 10);
244
+ const spy = vi.fn();
245
+ effect(() => {
246
+ spy(outer.value);
247
+ return undefined;
248
+ });
249
+ expect(spy).toHaveBeenCalledWith(12); // 1*2+10
250
+
251
+ spy.mockClear();
252
+ throwOnEquals = true;
253
+ s.value = 2;
254
+ // inner.execute() catches the equals throw and treats as changed
255
+ // outer should still update
256
+ expect(spy).toHaveBeenCalledWith(14); // 2*2+10
257
+ });
258
+
259
+ it("deeply chained computeds with middle one throwing equals", () => {
260
+ const s = signal(1);
261
+ const c1 = computed(() => s.value);
262
+ const c2 = computed(() => c1.value * 2, {
263
+ equals: () => {
264
+ throw new Error("c2 eq");
265
+ },
266
+ });
267
+ const c3 = computed(() => c2.value + 100);
268
+ const spy = vi.fn();
269
+ effect(() => {
270
+ spy(c3.value);
271
+ return undefined;
272
+ });
273
+ expect(spy).toHaveBeenCalledWith(102); // 1*2+100
274
+
275
+ spy.mockClear();
276
+ s.value = 5;
277
+ expect(spy).toHaveBeenCalledWith(110); // 5*2+100
278
+ });
279
+ });
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // dispose() called from inside another effect
283
+ // ---------------------------------------------------------------------------
284
+
285
+ describe("dispose from inside another effect", () => {
286
+ it("disposing effect B from inside effect A does not crash", () => {
287
+ const s = signal(0);
288
+ const spyB = vi.fn();
289
+ const disposeB = effect(() => {
290
+ spyB(s.value);
291
+ return undefined;
292
+ });
293
+
294
+ effect(() => {
295
+ if (s.value === 1) {
296
+ disposeB(); // dispose B from inside A's execution
297
+ }
298
+ return undefined;
299
+ });
300
+
301
+ spyB.mockClear();
302
+ s.value = 1; // A runs and disposes B; B should not run after disposal
303
+ // B might have already run for value=1 before A disposes it (order-dependent)
304
+ // But after this, B should definitely not run again
305
+ spyB.mockClear();
306
+ s.value = 2;
307
+ expect(spyB).not.toHaveBeenCalled();
308
+ });
309
+
310
+ it("disposing self from cleanup does not crash or overflow stack", () => {
311
+ const s = signal(0);
312
+ let disposeRef: (() => void) | null = null;
313
+ const errors: unknown[] = [];
314
+ const prev = setEffectErrorHandler((e) => {
315
+ errors.push(e);
316
+ });
317
+ disposeRef = effect(() => {
318
+ void s.value;
319
+ return () => {
320
+ // cleanup tries to dispose self (already being disposed)
321
+ if (disposeRef) {
322
+ disposeRef();
323
+ }
324
+ };
325
+ });
326
+ // Trigger re-run (which calls cleanup which calls dispose)
327
+ s.value = 1;
328
+ // Should not have stack overflow error
329
+ expect(errors.every((e) => !(e instanceof RangeError))).toBe(true);
330
+ setEffectErrorHandler(prev);
331
+ });
332
+ });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // on() with empty deps
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe("on() with empty deps", () => {
339
+ it("on() with empty array deps runs once and never re-triggers", () => {
340
+ const s = signal(0);
341
+ const spy = vi.fn();
342
+ effect(
343
+ on([] as (() => unknown)[], (vals) => {
344
+ // Read s inside body (should not be tracked due to untracked in on())
345
+ spy(vals, s.value);
346
+ return undefined;
347
+ }),
348
+ );
349
+ expect(spy).toHaveBeenCalledWith([], 0);
350
+ spy.mockClear();
351
+ s.value = 1;
352
+ expect(spy).not.toHaveBeenCalled();
353
+ });
354
+
355
+ it("on() with single dep returning undefined", () => {
356
+ const s = signal<undefined>(undefined);
357
+ const spy = vi.fn();
358
+ effect(
359
+ on(
360
+ () => s.value,
361
+ (v) => {
362
+ spy(v);
363
+ return undefined;
364
+ },
365
+ ),
366
+ );
367
+ expect(spy).toHaveBeenCalledWith(undefined);
368
+ });
369
+ });
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Store deep nesting
373
+ // ---------------------------------------------------------------------------
374
+
375
+ describe("store deep nesting", () => {
376
+ it("deeply nested batch (10 levels) still flushes correctly", () => {
377
+ const store = createStore<{ x: number }>();
378
+ const spy = vi.fn();
379
+ store.subscribe("x", spy);
380
+
381
+ const nest = (depth: number, fn: () => void): void => {
382
+ if (depth === 0) {
383
+ fn();
384
+ return;
385
+ }
386
+ store.batch(() => nest(depth - 1, fn));
387
+ };
388
+
389
+ nest(10, () => {
390
+ store.set("x", 42);
391
+ });
392
+ expect(spy).toHaveBeenCalledWith(42);
393
+ expect(spy).toHaveBeenCalledTimes(1);
394
+ });
395
+
396
+ it("store effect with many keys tracked", () => {
397
+ const store = createStore<Record<string, number>>();
398
+ for (let i = 0; i < 100; i++) {
399
+ store.set(`k${i}` as never, i as never);
400
+ }
401
+ const spy = vi.fn();
402
+ store.effect(() => {
403
+ let sum = 0;
404
+ for (let i = 0; i < 100; i++) {
405
+ sum += store.get(`k${i}` as never) as number;
406
+ }
407
+ spy(sum);
408
+ return undefined;
409
+ });
410
+ expect(spy).toHaveBeenCalledWith(4950); // sum 0..99
411
+ spy.mockClear();
412
+ store.set("k0" as never, 100 as never);
413
+ expect(spy).toHaveBeenCalledWith(5050); // 4950 - 0 + 100
414
+ });
415
+ });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Reconcile with thousands of keyed nodes / moved + removed combos
419
+ // ---------------------------------------------------------------------------
420
+
421
+ describe("reconcile: large scale", () => {
422
+ it("1000 keyed nodes: full reverse", () => {
423
+ const parent = document.createElement("div");
424
+ const items = Array.from({ length: 1000 }, (_, i) => ({ id: `k${i}`, label: `L${i}` }));
425
+ const spec = {
426
+ key: (i: { id: string; label: string }) => i.id,
427
+ mount: (i: { id: string; label: string }) => {
428
+ const el = document.createElement("span");
429
+ el.textContent = i.label;
430
+ return el;
431
+ },
432
+ update: (el: HTMLElement, i: { id: string; label: string }) => {
433
+ el.textContent = i.label;
434
+ },
435
+ };
436
+
437
+ reconcile(parent, items, spec);
438
+ expect(parent.children.length).toBe(1000);
439
+
440
+ // Capture references
441
+ const firstEl = parent.children[0]!;
442
+ const lastEl = parent.children[999]!;
443
+
444
+ // Full reverse
445
+ const reversed = [...items].reverse();
446
+ reconcile(parent, reversed, spec);
447
+ expect(parent.children.length).toBe(1000);
448
+ expect(parent.children[0]).toBe(lastEl);
449
+ expect(parent.children[999]).toBe(firstEl);
450
+ });
451
+
452
+ it("1000 nodes: remove every other, then re-add", () => {
453
+ const parent = document.createElement("div");
454
+ const items = Array.from({ length: 1000 }, (_, i) => ({ id: `k${i}`, label: `L${i}` }));
455
+ const removed: string[] = [];
456
+ const spec = {
457
+ key: (i: { id: string; label: string }) => i.id,
458
+ mount: (i: { id: string; label: string }) => {
459
+ const el = document.createElement("span");
460
+ el.textContent = i.label;
461
+ return el;
462
+ },
463
+ update: (el: HTMLElement, i: { id: string; label: string }) => {
464
+ el.textContent = i.label;
465
+ },
466
+ onRemove: (_el: HTMLElement, key: string) => {
467
+ removed.push(key);
468
+ },
469
+ };
470
+
471
+ reconcile(parent, items, spec);
472
+
473
+ // Remove every other
474
+ const evens = items.filter((_, i) => i % 2 === 0);
475
+ reconcile(parent, evens, spec);
476
+ expect(parent.children.length).toBe(500);
477
+ expect(removed.length).toBe(500);
478
+
479
+ // Re-add all
480
+ removed.length = 0;
481
+ reconcile(parent, items, spec);
482
+ expect(parent.children.length).toBe(1000);
483
+ expect(removed.length).toBe(0);
484
+ });
485
+
486
+ it("shuffle 1000 nodes preserves identity", () => {
487
+ const parent = document.createElement("div");
488
+ const items = Array.from({ length: 1000 }, (_, i) => ({ id: `k${i}`, label: `L${i}` }));
489
+ const spec = {
490
+ key: (i: { id: string; label: string }) => i.id,
491
+ mount: (i: { id: string; label: string }) => {
492
+ const el = document.createElement("span");
493
+ el.textContent = i.label;
494
+ return el;
495
+ },
496
+ update: (el: HTMLElement, i: { id: string; label: string }) => {
497
+ el.textContent = i.label;
498
+ },
499
+ };
500
+
501
+ reconcile(parent, items, spec);
502
+ const refMap = new Map<string, Element>();
503
+ for (const el of Array.from(parent.children)) {
504
+ refMap.set(el.getAttribute("data-reconcile-key")!, el);
505
+ }
506
+
507
+ // Deterministic shuffle (Fisher-Yates with fixed seed)
508
+ const shuffled = [...items];
509
+ let seed = 12345;
510
+ for (let i = shuffled.length - 1; i > 0; i--) {
511
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
512
+ const j = seed % (i + 1);
513
+ [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
514
+ }
515
+
516
+ reconcile(parent, shuffled, spec);
517
+ expect(parent.children.length).toBe(1000);
518
+
519
+ // Verify identity preservation
520
+ for (const el of Array.from(parent.children)) {
521
+ const key = el.getAttribute("data-reconcile-key")!;
522
+ expect(el).toBe(refMap.get(key));
523
+ }
524
+ });
525
+ });
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Store: dispose from cleanup does not overflow
529
+ // ---------------------------------------------------------------------------
530
+
531
+ describe("store: dispose from cleanup", () => {
532
+ it("store effect dispose called from cleanup does not overflow", () => {
533
+ const store = createStore<{ x: number }>();
534
+ store.set("x", 0);
535
+ let disposeRef: (() => void) | null = null;
536
+ disposeRef = store.effect(() => {
537
+ void store.get("x");
538
+ return () => {
539
+ if (disposeRef) {
540
+ disposeRef();
541
+ }
542
+ };
543
+ });
544
+ // Trigger re-run which calls cleanup which calls dispose
545
+ expect(() => {
546
+ store.set("x", 1);
547
+ }).not.toThrow();
548
+ });
549
+
550
+ it("store effect double dispose is no-op", () => {
551
+ const store = createStore<{ x: number }>();
552
+ store.set("x", 0);
553
+ let cleanupCount = 0;
554
+ const dispose = store.effect(() => {
555
+ void store.get("x");
556
+ return () => {
557
+ cleanupCount++;
558
+ };
559
+ });
560
+ dispose();
561
+ expect(cleanupCount).toBe(1);
562
+ dispose();
563
+ expect(cleanupCount).toBe(1);
564
+ });
565
+ });
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // flushSync edge cases
569
+ // ---------------------------------------------------------------------------
570
+
571
+ describe("flushSync", () => {
572
+ it("flushSync outside batch drains pending", () => {
573
+ const s = signal(0);
574
+ const spy = vi.fn();
575
+ effect(() => {
576
+ spy(s.value);
577
+ return undefined;
578
+ });
579
+ spy.mockClear();
580
+ // Normally writes flush immediately, but let's verify flushSync is a no-op
581
+ // when nothing is pending
582
+ flushSync();
583
+ expect(spy).not.toHaveBeenCalled();
584
+ });
585
+
586
+ it("flushSync inside batch is a no-op", () => {
587
+ const s = signal(0);
588
+ const spy = vi.fn();
589
+ effect(() => {
590
+ spy(s.value);
591
+ return undefined;
592
+ });
593
+ spy.mockClear();
594
+ batch(() => {
595
+ s.value = 1;
596
+ flushSync(); // should be no-op inside batch
597
+ expect(spy).not.toHaveBeenCalled();
598
+ });
599
+ expect(spy).toHaveBeenCalledWith(1);
600
+ });
601
+ });
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Computed: dirty flag after disposal of downstream effect
605
+ // ---------------------------------------------------------------------------
606
+
607
+ describe("computed: lazy after effect disposal", () => {
608
+ it("computed stays fresh after all effect subscribers are disposed", () => {
609
+ const s = signal(1);
610
+ const c = computed(() => s.value * 10);
611
+ const spy = vi.fn();
612
+ const dispose = effect(() => {
613
+ spy(c.value);
614
+ return undefined;
615
+ });
616
+ expect(spy).toHaveBeenCalledWith(10);
617
+ dispose();
618
+ s.value = 5;
619
+ // Computed should still give correct value when read lazily
620
+ expect(c.value).toBe(50);
621
+ });
622
+ });
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Signal: peek() does not track
626
+ // ---------------------------------------------------------------------------
627
+
628
+ describe("signal peek", () => {
629
+ it("peek inside effect does not create subscription", () => {
630
+ const s = signal(0);
631
+ const spy = vi.fn();
632
+ effect(() => {
633
+ spy(s.peek());
634
+ return undefined;
635
+ });
636
+ expect(spy).toHaveBeenCalledWith(0);
637
+ spy.mockClear();
638
+ s.value = 1;
639
+ expect(spy).not.toHaveBeenCalled();
640
+ });
641
+ });
642
+
643
+ // ---------------------------------------------------------------------------
644
+ // Effect cleanup ordering with multiple effects
645
+ // ---------------------------------------------------------------------------
646
+
647
+ describe("effect cleanup ordering", () => {
648
+ it("cleanup of effect A runs before effect A re-executes, not before B", () => {
649
+ const s = signal(0);
650
+ const order: string[] = [];
651
+ effect(() => {
652
+ order.push(`A:${s.value}`);
653
+ return () => {
654
+ order.push(`cleanup-A:${s.value}`);
655
+ };
656
+ });
657
+ effect(() => {
658
+ order.push(`B:${s.value}`);
659
+ return () => {
660
+ order.push(`cleanup-B:${s.value}`);
661
+ };
662
+ });
663
+ order.length = 0;
664
+ s.value = 1;
665
+ // Each effect's cleanup runs before its own re-execution
666
+ expect(order).toContain("cleanup-A:1");
667
+ expect(order).toContain("cleanup-B:1");
668
+ expect(order.indexOf("cleanup-A:1")).toBeLessThan(order.indexOf("A:1"));
669
+ expect(order.indexOf("cleanup-B:1")).toBeLessThan(order.indexOf("B:1"));
670
+ });
671
+ });
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Computed: error recovery in effect
675
+ // ---------------------------------------------------------------------------
676
+
677
+ describe("computed error recovery in effect", () => {
678
+ let prevHandler: EffectErrorHandler;
679
+ const errors: unknown[] = [];
680
+ beforeEach(() => {
681
+ errors.length = 0;
682
+ prevHandler = setEffectErrorHandler((e) => {
683
+ errors.push(e);
684
+ });
685
+ });
686
+ afterEach(() => {
687
+ setEffectErrorHandler(prevHandler);
688
+ });
689
+
690
+ it("effect recovers after computed stops throwing", () => {
691
+ const s = signal(0);
692
+ const c = computed(() => {
693
+ if (s.value === 0) {
694
+ throw new Error("zero");
695
+ }
696
+ return s.value * 2;
697
+ });
698
+ const values: number[] = [];
699
+ effect(() => {
700
+ values.push(c.value);
701
+ return undefined;
702
+ });
703
+ // First run throws
704
+ expect(errors.length).toBe(1);
705
+ expect(values).toEqual([]);
706
+
707
+ // Fix the signal
708
+ s.value = 5;
709
+ expect(values).toEqual([10]);
710
+ });
711
+ });