@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,658 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // RED-TEAM round-7 — final convergence verification
3
- // Per-callback-site throw-safety + pathological graphs (diamonds, chains, fan-in/out)
4
- import { describe, it, expect, vi } from "vitest";
5
- import { signal, effect, batch, computed, setEffectErrorHandler, flushSync } from "./index.js";
6
- import { createStore } from "./store.js";
7
- import { reconcile, KEY_ATTR } from "./reconcile.js";
8
-
9
- function withHandler(fn: (errors: unknown[]) => void): void {
10
- const errors: unknown[] = [];
11
- const prev = setEffectErrorHandler((e) => errors.push(e));
12
- try {
13
- fn(errors);
14
- } finally {
15
- setEffectErrorHandler(prev);
16
- }
17
- }
18
-
19
- // ===========================================================================
20
- // CALLBACK SITE 1: effect body throw in pathological graphs
21
- // ===========================================================================
22
- describe("R7: effect body throw — pathological graphs", () => {
23
- it("diamond graph: root → L,R → join; L throws — join still subscribes", () => {
24
- withHandler((errors) => {
25
- const root = signal(0);
26
- const left = computed(() => root.value + 1);
27
- const right = computed(() => root.value + 2);
28
- const log: string[] = [];
29
- // L effect throws
30
- effect(() => {
31
- if (left.value > 1) {
32
- throw new Error("L-boom");
33
- }
34
- return undefined;
35
- });
36
- // Join effect reads both
37
- effect(() => {
38
- log.push(`${left.value}+${right.value}`);
39
- return undefined;
40
- });
41
- log.length = 0;
42
- errors.length = 0;
43
- root.value = 1;
44
- expect(errors.length).toBe(1);
45
- expect(log).toContain("2+3");
46
- // Verify join still subscribes after L threw
47
- log.length = 0;
48
- root.value = 2;
49
- expect(log).toContain("3+4");
50
- });
51
- });
52
-
53
- it("fan-out: one source → 20 effects, all throw — no skipped, all re-subscribe", () => {
54
- withHandler((errors) => {
55
- const s = signal(0);
56
- const spies = Array.from({ length: 20 }, () => vi.fn());
57
- spies.forEach((spy, i) => {
58
- effect(() => {
59
- spy(s.value);
60
- if (s.value > 0) {
61
- throw new Error(`fan-${i}`);
62
- }
63
- return undefined;
64
- });
65
- });
66
- spies.forEach((spy) => spy.mockClear());
67
- errors.length = 0;
68
- s.value = 1;
69
- expect(errors.length).toBe(20);
70
- spies.forEach((spy) => expect(spy).toHaveBeenCalledWith(1));
71
- // All re-subscribe
72
- spies.forEach((spy) => spy.mockClear());
73
- errors.length = 0;
74
- s.value = 2;
75
- expect(errors.length).toBe(20);
76
- spies.forEach((spy) => expect(spy).toHaveBeenCalledWith(2));
77
- });
78
- });
79
-
80
- it("chain: s → c1 → c2 → c3 → effect; c2 throws — effect still works after recovery", () => {
81
- withHandler(() => {
82
- const s = signal(0);
83
- const c1 = computed(() => s.value * 2);
84
- let throwInC2 = false;
85
- const c2 = computed(() => {
86
- if (throwInC2) {
87
- throw new Error("c2-chain");
88
- }
89
- return c1.value + 1;
90
- });
91
- const c3 = computed(() => c2.value + 10);
92
- const log: string[] = [];
93
- effect(() => {
94
- try {
95
- log.push(`v=${c3.value}`);
96
- } catch {
97
- log.push("err");
98
- }
99
- return undefined;
100
- });
101
- log.length = 0;
102
- throwInC2 = true;
103
- s.value = 1;
104
- expect(log).toContain("err");
105
- // Recovery
106
- log.length = 0;
107
- throwInC2 = false;
108
- s.value = 2;
109
- expect(log).toContain("v=15"); // 2*2+1+10=15
110
- });
111
- });
112
-
113
- it("fan-in: 5 signals → 1 computed → effect; throw in effect body", () => {
114
- withHandler((errors) => {
115
- const sigs = Array.from({ length: 5 }, (_, i) => signal(i));
116
- const sum = computed(() => sigs.reduce((a, s) => a + s.value, 0));
117
- const spy = vi.fn();
118
- effect(() => {
119
- spy(sum.value);
120
- if (sum.value > 10) {
121
- throw new Error("fan-in-boom");
122
- }
123
- return undefined;
124
- });
125
- spy.mockClear();
126
- errors.length = 0;
127
- batch(() => {
128
- sigs.forEach((s) => {
129
- s.value = 10;
130
- });
131
- });
132
- expect(errors.length).toBe(1);
133
- expect(spy).toHaveBeenCalledWith(50);
134
- // Still subscribes
135
- spy.mockClear();
136
- errors.length = 0;
137
- sigs[0]!.value = 0;
138
- expect(spy).toHaveBeenCalledWith(40);
139
- });
140
- });
141
- });
142
-
143
- // ===========================================================================
144
- // CALLBACK SITE 2: effect cleanup throw — pathological cases
145
- // ===========================================================================
146
- describe("R7: effect cleanup throw — pathological", () => {
147
- it("cleanup throws then effect body throws — both caught, effect re-subscribes", () => {
148
- withHandler((errors) => {
149
- const s = signal(0);
150
- let throwCount = 0;
151
- effect(() => {
152
- void s.value;
153
- throwCount++;
154
- if (throwCount > 1) {
155
- throw new Error("body-throw");
156
- }
157
- return () => {
158
- throw new Error("cleanup-throw");
159
- };
160
- });
161
- errors.length = 0;
162
- s.value = 1; // triggers cleanup (throws) then body (throws)
163
- expect(errors.length).toBe(2);
164
- // Still subscribed for next update
165
- errors.length = 0;
166
- throwCount = 0; // reset to not throw
167
- s.value = 2;
168
- // Effect ran (throwCount incremented)
169
- expect(throwCount).toBe(1);
170
- });
171
- });
172
-
173
- it("cleanup throws during dispose — no zombie", () => {
174
- withHandler((errors) => {
175
- const s = signal(0);
176
- const spy = vi.fn();
177
- const dispose = effect(() => {
178
- spy(s.value);
179
- return () => {
180
- throw new Error("dispose-cleanup");
181
- };
182
- });
183
- spy.mockClear();
184
- errors.length = 0;
185
- dispose();
186
- expect(errors.length).toBe(1);
187
- spy.mockClear();
188
- s.value = 1;
189
- expect(spy).not.toHaveBeenCalled(); // no zombie
190
- });
191
- });
192
- });
193
-
194
- // ===========================================================================
195
- // CALLBACK SITE 3: computed fn throw — pathological
196
- // ===========================================================================
197
- describe("R7: computed fn throw — pathological graphs", () => {
198
- it("diamond: root → cL(throws), cR → join-computed → effect", () => {
199
- withHandler(() => {
200
- const root = signal(0);
201
- const cL = computed(() => {
202
- if (root.value > 0) {
203
- throw new Error("cL");
204
- }
205
- return root.value;
206
- });
207
- const cR = computed(() => root.value * 10);
208
- const join = computed(() => {
209
- let l: number;
210
- try {
211
- l = cL.value;
212
- } catch {
213
- l = -1;
214
- }
215
- return l + cR.value;
216
- });
217
- const log: number[] = [];
218
- effect(() => {
219
- log.push(join.value);
220
- return undefined;
221
- });
222
- log.length = 0;
223
- root.value = 1;
224
- expect(log).toContain(-1 + 10); // -1 + 10 = 9
225
- // cL recovers
226
- log.length = 0;
227
- root.value = -1;
228
- expect(log).toContain(-1 + -10); // -1 + -10 = -11
229
- });
230
- });
231
-
232
- it("computed throws mid-tracking — partial deps preserved for retry (no zombie)", () => {
233
- withHandler(() => {
234
- const a = signal(1);
235
- const b = signal(2);
236
- let shouldThrow = false;
237
- const c = computed(() => {
238
- const av = a.value; // subscribes to a
239
- if (shouldThrow) {
240
- throw new Error("mid-track");
241
- }
242
- return av + b.value; // subscribes to b only if no throw
243
- });
244
- const spy = vi.fn();
245
- effect(() => {
246
- try {
247
- spy(c.value);
248
- } catch {
249
- spy("err");
250
- }
251
- return undefined;
252
- });
253
- expect(spy).toHaveBeenCalledWith(3);
254
- shouldThrow = true;
255
- spy.mockClear();
256
- a.value = 10; // triggers recompute, throws mid-track
257
- expect(spy).toHaveBeenCalledWith("err");
258
- // The computed restored old deps (a,b) — changing b triggers recompute.
259
- // But since the computed still errors with same "shape" (hasError=true, val unchanged),
260
- // the equality dedup correctly skips re-notification. This is correct behavior.
261
- // The key invariant: when we RECOVER, changing b triggers the recompute.
262
- shouldThrow = false;
263
- spy.mockClear();
264
- b.value = 20; // triggers recompute because b dep was preserved
265
- // Now it succeeds: a=10, b=20 → 30
266
- expect(spy).toHaveBeenCalledWith(30);
267
- });
268
- });
269
- });
270
-
271
- // ===========================================================================
272
- // CALLBACK SITE 4: computed equals throw
273
- // ===========================================================================
274
- describe("R7: computed equals comparator throw", () => {
275
- it("equals throws — treated as changed, downstream notified, no corruption", () => {
276
- withHandler((_errors) => {
277
- const s = signal(0);
278
- let eqThrow = false;
279
- const c = computed(() => s.value * 2, {
280
- equals: () => {
281
- if (eqThrow) {
282
- throw new Error("eq-boom");
283
- }
284
- return false;
285
- },
286
- });
287
- const spy = vi.fn();
288
- effect(() => {
289
- spy(c.value);
290
- return undefined;
291
- });
292
- spy.mockClear();
293
- eqThrow = true;
294
- s.value = 1;
295
- // The equals throw in computed.sub.execute should be caught (try/catch around eq)
296
- // and treated as changed=true, so downstream gets notified
297
- expect(spy).toHaveBeenCalledWith(2);
298
- // System still works
299
- eqThrow = false;
300
- spy.mockClear();
301
- s.value = 2;
302
- expect(spy).toHaveBeenCalledWith(4);
303
- // No errors reported to handler (eq throw is internal to computed machinery)
304
- // Actually check: computed's execute() catches eq throw internally
305
- });
306
- });
307
- });
308
-
309
- // ===========================================================================
310
- // CALLBACK SITE 5: signal equals throw
311
- // ===========================================================================
312
- describe("R7: signal equals throw", () => {
313
- it("equals throws inside deeply nested batch — batchDepth restored", () => {
314
- const s = signal(0, {
315
- equals: (_a, b) => {
316
- if (b === 100) {
317
- throw new Error("deep-eq");
318
- }
319
- return _a === b;
320
- },
321
- });
322
- const spy = vi.fn();
323
- const prev = setEffectErrorHandler(() => {
324
- /* swallow */
325
- });
326
- effect(() => {
327
- spy(s.value);
328
- return undefined;
329
- });
330
- spy.mockClear();
331
- expect(() => {
332
- batch(() =>
333
- batch(() =>
334
- batch(() => {
335
- s.value = 100;
336
- }),
337
- ),
338
- );
339
- }).toThrow("deep-eq");
340
- // batchDepth must be 0 — next write must flush immediately
341
- spy.mockClear();
342
- s.value = 7;
343
- expect(spy).toHaveBeenCalledWith(7);
344
- setEffectErrorHandler(prev);
345
- });
346
-
347
- it("equals throws — does not corrupt signal value or subscriber set", () => {
348
- const s = signal(42, {
349
- equals: (_a, b) => {
350
- if (b === 0) {
351
- throw new Error("zero-eq");
352
- }
353
- return _a === b;
354
- },
355
- });
356
- const spy = vi.fn();
357
- const prev = setEffectErrorHandler(() => {
358
- /* swallow */
359
- });
360
- effect(() => {
361
- spy(s.value);
362
- return undefined;
363
- });
364
- spy.mockClear();
365
- expect(() => {
366
- s.value = 0;
367
- }).toThrow("zero-eq");
368
- expect(s.peek()).toBe(42); // value unchanged
369
- spy.mockClear();
370
- s.value = 99;
371
- expect(spy).toHaveBeenCalledWith(99);
372
- expect(s.peek()).toBe(99);
373
- setEffectErrorHandler(prev);
374
- });
375
- });
376
-
377
- // ===========================================================================
378
- // CALLBACK SITE 6: store subscriber throw
379
- // ===========================================================================
380
- describe("R7: store subscriber throw", () => {
381
- it("subscriber throws — other subscribers still notified", () => {
382
- const store = createStore<{ x: number }>();
383
- store.set("x", 0);
384
- const spy1 = vi.fn();
385
- const spy2 = vi.fn();
386
- store.subscribe("x", () => {
387
- throw new Error("sub-throw");
388
- });
389
- store.subscribe("x", spy1);
390
- store.subscribe("x", spy2);
391
- store.set("x", 5);
392
- expect(spy1).toHaveBeenCalledWith(5);
393
- expect(spy2).toHaveBeenCalledWith(5);
394
- });
395
-
396
- it("store batch — subscriber throw doesn't corrupt batchDepth", () => {
397
- const store = createStore<{ a: number; b: number }>();
398
- store.set("a", 0);
399
- store.set("b", 0);
400
- store.subscribe("a", () => {
401
- throw new Error("a-sub");
402
- });
403
- const spy = vi.fn();
404
- store.subscribe("b", spy);
405
- store.batch(() => {
406
- store.set("a", 1);
407
- store.set("b", 2);
408
- });
409
- expect(spy).toHaveBeenCalledWith(2);
410
- // Verify non-batch still works after
411
- spy.mockClear();
412
- store.set("b", 3);
413
- expect(spy).toHaveBeenCalledWith(3);
414
- });
415
- });
416
-
417
- // ===========================================================================
418
- // CALLBACK SITE 7: reconcile mount/update/onRemove/key throw
419
- // (These don't touch reactive state, just verify no infinite loop/hang)
420
- // ===========================================================================
421
- describe("R7: reconcile callbacks throw — no hang", () => {
422
- it("key throws — propagates, no reactive corruption", () => {
423
- const parent = document.createElement("div");
424
- expect(() => {
425
- reconcile(parent, [{ id: 1 }], {
426
- key: () => {
427
- throw new Error("key-throw");
428
- },
429
- mount: (item) => {
430
- const el = document.createElement("div");
431
- el.textContent = String(item);
432
- return el;
433
- },
434
- });
435
- }).toThrow("key-throw");
436
- // Reactive system still works after
437
- const s = signal(0);
438
- const spy = vi.fn();
439
- const prev = setEffectErrorHandler(() => {
440
- /* swallow */
441
- });
442
- effect(() => {
443
- spy(s.value);
444
- return undefined;
445
- });
446
- spy.mockClear();
447
- s.value = 1;
448
- expect(spy).toHaveBeenCalledWith(1);
449
- setEffectErrorHandler(prev);
450
- });
451
-
452
- it("mount throws — propagates, no reactive corruption", () => {
453
- const parent = document.createElement("div");
454
- expect(() => {
455
- reconcile(parent, [{ id: 1 }], {
456
- key: (item) => String(item.id),
457
- mount: () => {
458
- throw new Error("mount-throw");
459
- },
460
- });
461
- }).toThrow("mount-throw");
462
- const s = signal(0);
463
- const spy = vi.fn();
464
- const prev = setEffectErrorHandler(() => {
465
- /* swallow */
466
- });
467
- effect(() => {
468
- spy(s.value);
469
- return undefined;
470
- });
471
- spy.mockClear();
472
- s.value = 1;
473
- expect(spy).toHaveBeenCalledWith(1);
474
- setEffectErrorHandler(prev);
475
- });
476
-
477
- it("update throws — propagates, no reactive corruption", () => {
478
- const parent = document.createElement("div");
479
- reconcile(parent, [{ id: 1, v: "a" }], {
480
- key: (item) => String(item.id),
481
- mount: (item) => {
482
- const el = document.createElement("div");
483
- el.setAttribute(KEY_ATTR, String(item.id));
484
- return el;
485
- },
486
- update: () => {
487
- throw new Error("update-throw");
488
- },
489
- });
490
- // Now update
491
- expect(() => {
492
- reconcile(parent, [{ id: 1, v: "b" }], {
493
- key: (item) => String(item.id),
494
- mount: (item) => {
495
- const el = document.createElement("div");
496
- el.setAttribute(KEY_ATTR, String(item.id));
497
- return el;
498
- },
499
- update: () => {
500
- throw new Error("update-throw");
501
- },
502
- });
503
- }).toThrow("update-throw");
504
- });
505
-
506
- it("onRemove throws — propagates, no reactive corruption", () => {
507
- const parent = document.createElement("div");
508
- const spec = {
509
- key: (item: { id: number }) => String(item.id),
510
- mount: (item: { id: number }) => {
511
- const el = document.createElement("div");
512
- el.setAttribute(KEY_ATTR, String(item.id));
513
- return el;
514
- },
515
- onRemove: () => {
516
- throw new Error("onRemove-throw");
517
- },
518
- };
519
- reconcile(parent, [{ id: 1 }, { id: 2 }], spec);
520
- expect(() => {
521
- reconcile(parent, [{ id: 1 }], spec); // removes id=2, onRemove throws
522
- }).toThrow("onRemove-throw");
523
- });
524
- });
525
-
526
- // ===========================================================================
527
- // INVARIANT CHECKS: flushing, batchDepth, no infinite loop
528
- // ===========================================================================
529
- describe("R7: global invariants after throw storms", () => {
530
- it("100 alternating throw/success cycles — system remains stable", () => {
531
- withHandler(() => {
532
- const s = signal(0);
533
- let shouldThrow = true;
534
- effect(() => {
535
- void s.value;
536
- if (shouldThrow) {
537
- throw new Error("storm");
538
- }
539
- return undefined;
540
- });
541
- for (let i = 1; i <= 100; i++) {
542
- shouldThrow = i % 2 === 0;
543
- s.value = i;
544
- }
545
- // Final state — effect should have run (shouldThrow=false for odd=100? no, 100%2=0 so throws)
546
- shouldThrow = false;
547
- const spy = vi.fn();
548
- effect(() => {
549
- spy(s.value);
550
- return undefined;
551
- });
552
- spy.mockClear();
553
- s.value = 999;
554
- expect(spy).toHaveBeenCalledWith(999);
555
- });
556
- });
557
-
558
- it("flushSync after throw storm — works (flushing not stuck)", () => {
559
- withHandler(() => {
560
- const s = signal(0);
561
- effect(() => {
562
- if (s.value > 0) {
563
- throw new Error("fs");
564
- }
565
- return undefined;
566
- });
567
- s.value = 1;
568
- // If flushing were stuck, this would be a no-op
569
- const s2 = signal(0);
570
- const spy = vi.fn();
571
- effect(() => {
572
- spy(s2.value);
573
- return undefined;
574
- });
575
- spy.mockClear();
576
- batch(() => {
577
- s2.value = 42;
578
- });
579
- flushSync();
580
- expect(spy).toHaveBeenCalledWith(42);
581
- });
582
- });
583
-
584
- it("no infinite loop: effect throws and re-dispatches to itself (writes in error path)", () => {
585
- // If effect throws, it's caught. If during that the error handler writes to
586
- // the same signal, the effect is re-queued. Could this loop forever?
587
- // Answer: no, because drainPending processes the current batch, and new
588
- // pending items are processed in the next while-loop iteration.
589
- // But let's verify it terminates.
590
- const s = signal(0);
591
- let writeCount = 0;
592
- const prev = setEffectErrorHandler(() => {
593
- // Write back to the signal — but only a few times
594
- if (writeCount < 5) {
595
- writeCount++;
596
- s.value = s.peek() + 1;
597
- }
598
- });
599
- effect(() => {
600
- if (s.value > 0 && s.value < 10) {
601
- throw new Error("loop-test");
602
- }
603
- return undefined;
604
- });
605
- s.value = 1;
606
- // Should terminate. writeCount should be 5 (handler stops writing after 5)
607
- expect(writeCount).toBe(5);
608
- expect(s.peek()).toBe(6); // 1 + 5 increments
609
- setEffectErrorHandler(prev);
610
- });
611
- });
612
-
613
- // ===========================================================================
614
- // CROSS-CUTTING: computed in effect that throws during batch flush
615
- // ===========================================================================
616
- describe("R7: computed+effect interaction during batch flush throw", () => {
617
- it("batch with 3 computeds, middle throws — first and last still propagate", () => {
618
- withHandler(() => {
619
- const s = signal(0);
620
- const c1 = computed(() => s.value + 1);
621
- const c2 = computed(() => {
622
- if (s.value > 0) {
623
- throw new Error("c2-batch");
624
- }
625
- return s.value + 2;
626
- });
627
- const c3 = computed(() => s.value + 3);
628
- const log1: number[] = [];
629
- const log2: string[] = [];
630
- const log3: number[] = [];
631
- effect(() => {
632
- log1.push(c1.value);
633
- return undefined;
634
- });
635
- effect(() => {
636
- try {
637
- log2.push(`v=${c2.value}`);
638
- } catch {
639
- log2.push("err");
640
- }
641
- return undefined;
642
- });
643
- effect(() => {
644
- log3.push(c3.value);
645
- return undefined;
646
- });
647
- log1.length = 0;
648
- log2.length = 0;
649
- log3.length = 0;
650
- batch(() => {
651
- s.value = 5;
652
- });
653
- expect(log1).toContain(6);
654
- expect(log2).toContain("err");
655
- expect(log3).toContain(8);
656
- });
657
- });
658
- });