@cplieger/reactive 1.0.1 → 1.0.3

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,650 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // RED-TEAM round-6 — convergence verification
3
- // Attacks: re-entrant writes during throw, computed recompute exception during
4
- // batch flush with downstream effects, multiple independent throwers in one
5
- // flush, throw then immediate new dispatch, equals throwing in signal setter,
6
- // dispose() in error handler, diamond with dual throwing computeds.
7
- import { describe, it, expect, vi } from "vitest";
8
- import { signal, effect, batch, computed, setEffectErrorHandler } from "./index.js";
9
-
10
- // ---------------------------------------------------------------------------
11
- // Helpers
12
- // ---------------------------------------------------------------------------
13
- function withHandler(fn: (errors: unknown[]) => void): void {
14
- const errors: unknown[] = [];
15
- const prev = setEffectErrorHandler((e) => {
16
- errors.push(e);
17
- });
18
- try {
19
- fn(errors);
20
- } finally {
21
- setEffectErrorHandler(prev);
22
- }
23
- }
24
-
25
- // ---------------------------------------------------------------------------
26
- // 1. Re-entrant signal write inside effectErrorHandler during flush
27
- // ---------------------------------------------------------------------------
28
- describe("re-entrant write inside effectErrorHandler during flush", () => {
29
- it("handler writes to another signal — downstream effect runs after flush", () => {
30
- const s = signal(0);
31
- const s2 = signal("init");
32
- const prev = setEffectErrorHandler(() => {
33
- s2.value = "from-handler";
34
- });
35
- const log: string[] = [];
36
- effect(() => {
37
- log.push(`s2=${s2.value}`);
38
- return undefined;
39
- });
40
- effect(() => {
41
- if (s.value > 0) {
42
- throw new Error("boom");
43
- }
44
- return undefined;
45
- });
46
- log.length = 0;
47
- s.value = 1;
48
- setEffectErrorHandler(prev);
49
- expect(log).toContain("s2=from-handler");
50
- });
51
-
52
- it("handler writes during batch — effects flush at batch end", () => {
53
- const s = signal(0);
54
- const s2 = signal(0);
55
- const prev = setEffectErrorHandler(() => {
56
- s2.value = 42;
57
- });
58
- const spy = vi.fn();
59
- effect(() => {
60
- spy(s2.value);
61
- return undefined;
62
- });
63
- effect(() => {
64
- if (s.value > 0) {
65
- throw new Error("x");
66
- }
67
- return undefined;
68
- });
69
- spy.mockClear();
70
- batch(() => {
71
- s.value = 1;
72
- });
73
- setEffectErrorHandler(prev);
74
- expect(spy).toHaveBeenCalledWith(42);
75
- });
76
- });
77
-
78
- // ---------------------------------------------------------------------------
79
- // 2. Exception in computed recompute during batch flush with downstream effects
80
- // ---------------------------------------------------------------------------
81
- describe("computed recompute throws during batch flush with downstream", () => {
82
- it("throwing computed doesn't prevent downstream effects from running", () => {
83
- withHandler((_errors) => {
84
- const s = signal(0);
85
- const bad = computed(() => {
86
- if (s.value > 0) {
87
- throw new Error("computed-recompute-boom");
88
- }
89
- return s.value;
90
- });
91
- const good = computed(() => s.value * 10);
92
- const logBad: string[] = [];
93
- const logGood: number[] = [];
94
- effect(() => {
95
- try {
96
- logBad.push(`v=${bad.value}`);
97
- } catch (_e) {
98
- logBad.push("err");
99
- }
100
- return undefined;
101
- });
102
- effect(() => {
103
- logGood.push(good.value);
104
- return undefined;
105
- });
106
- logBad.length = 0;
107
- logGood.length = 0;
108
- batch(() => {
109
- s.value = 5;
110
- });
111
- expect(logBad).toContain("err");
112
- expect(logGood).toContain(50);
113
- });
114
- });
115
-
116
- it("computed throws then recovers — effect tracks again", () => {
117
- const prev = setEffectErrorHandler(() => {
118
- // swallow
119
- });
120
- const s = signal(0);
121
- let shouldThrow = false;
122
- const c = computed(() => {
123
- if (shouldThrow) {
124
- throw new Error("temp-fail");
125
- }
126
- return s.value * 2;
127
- });
128
- const log: string[] = [];
129
- effect(() => {
130
- try {
131
- log.push(`v=${c.value}`);
132
- } catch (_e) {
133
- log.push("err");
134
- }
135
- return undefined;
136
- });
137
- shouldThrow = true;
138
- s.value = 1;
139
- shouldThrow = false;
140
- s.value = 2;
141
- setEffectErrorHandler(prev);
142
- expect(log).toContain("v=4"); // recovered
143
- });
144
- });
145
-
146
- // ---------------------------------------------------------------------------
147
- // 3. Multiple independent throwers in one flush
148
- // ---------------------------------------------------------------------------
149
- describe("multiple independent throwers in one flush", () => {
150
- it("10 effects all throw — all are caught, system is stable after", () => {
151
- withHandler((errors) => {
152
- const s = signal(0);
153
- const spies: ReturnType<typeof vi.fn>[] = [];
154
- for (let i = 0; i < 10; i++) {
155
- const spy = vi.fn();
156
- spies.push(spy);
157
- effect(() => {
158
- spy(s.value);
159
- if (s.value > 0) {
160
- throw new Error(`multi-throw-${i}`);
161
- }
162
- return undefined;
163
- });
164
- }
165
- for (const spy of spies) {
166
- spy.mockClear();
167
- }
168
- errors.length = 0;
169
- s.value = 1;
170
- expect(errors.length).toBe(10);
171
- for (const spy of spies) {
172
- expect(spy).toHaveBeenCalledWith(1);
173
- }
174
- // System stable: all re-subscribe and can run again
175
- for (const spy of spies) {
176
- spy.mockClear();
177
- }
178
- errors.length = 0;
179
- s.value = 2;
180
- expect(errors.length).toBe(10);
181
- for (const spy of spies) {
182
- expect(spy).toHaveBeenCalledWith(2);
183
- }
184
- });
185
- });
186
-
187
- it("mix: some throw, some don't — non-throwers run correctly", () => {
188
- withHandler((errors) => {
189
- const s = signal(0);
190
- const goodSpy = vi.fn();
191
- const badSpy = vi.fn();
192
- effect(() => {
193
- badSpy(s.value);
194
- if (s.value > 0) {
195
- throw new Error("bad");
196
- }
197
- return undefined;
198
- });
199
- effect(() => {
200
- goodSpy(s.value);
201
- return undefined;
202
- });
203
- goodSpy.mockClear();
204
- badSpy.mockClear();
205
- errors.length = 0;
206
- s.value = 5;
207
- expect(goodSpy).toHaveBeenCalledWith(5);
208
- expect(badSpy).toHaveBeenCalledWith(5);
209
- expect(errors.length).toBe(1);
210
- });
211
- });
212
- });
213
-
214
- // ---------------------------------------------------------------------------
215
- // 4. Throw then immediate new dispatch
216
- // ---------------------------------------------------------------------------
217
- describe("throw then immediate new dispatch", () => {
218
- it("write after a throwing flush works correctly", () => {
219
- const prev = setEffectErrorHandler(() => {
220
- // swallow
221
- });
222
- const s = signal(0);
223
- const spy = vi.fn();
224
- effect(() => {
225
- spy(s.value);
226
- if (s.value === 1) {
227
- throw new Error("first-throw");
228
- }
229
- return undefined;
230
- });
231
- spy.mockClear();
232
- s.value = 1; // throws inside effect
233
- spy.mockClear();
234
- s.value = 2; // must still work
235
- expect(spy).toHaveBeenCalledWith(2);
236
- setEffectErrorHandler(prev);
237
- });
238
-
239
- it("batch, throw, then immediate non-batch write", () => {
240
- const prev = setEffectErrorHandler(() => {
241
- // swallow
242
- });
243
- const s = signal(0);
244
- const spy = vi.fn();
245
- effect(() => {
246
- spy(s.value);
247
- if (s.value === 10) {
248
- throw new Error("batch-throw");
249
- }
250
- return undefined;
251
- });
252
- spy.mockClear();
253
- batch(() => {
254
- s.value = 10;
255
- });
256
- spy.mockClear();
257
- s.value = 20;
258
- expect(spy).toHaveBeenCalledWith(20);
259
- setEffectErrorHandler(prev);
260
- });
261
-
262
- it("rapid alternating throw/no-throw", () => {
263
- const prev = setEffectErrorHandler(() => {
264
- // swallow
265
- });
266
- const s = signal(0);
267
- let lastSeen = -1;
268
- effect(() => {
269
- lastSeen = s.value;
270
- if (s.value % 2 === 1) {
271
- throw new Error("odd");
272
- }
273
- return undefined;
274
- });
275
- for (let i = 1; i <= 100; i++) {
276
- s.value = i;
277
- }
278
- expect(lastSeen).toBe(100);
279
- setEffectErrorHandler(prev);
280
- });
281
- });
282
-
283
- // ---------------------------------------------------------------------------
284
- // 5. Signal equals function throwing (from user code writing to signal)
285
- // ---------------------------------------------------------------------------
286
- describe("signal equals function throwing", () => {
287
- it("equals throw propagates to caller but doesn't corrupt system", () => {
288
- const s = signal(0, {
289
- equals: (_a, b) => {
290
- if (b === 5) {
291
- throw new Error("equals-boom");
292
- }
293
- return _a === b;
294
- },
295
- });
296
- const spy = vi.fn();
297
- const prev = setEffectErrorHandler(() => {
298
- // swallow
299
- });
300
- effect(() => {
301
- spy(s.value);
302
- return undefined;
303
- });
304
- spy.mockClear();
305
- expect(() => {
306
- s.value = 5;
307
- }).toThrow("equals-boom");
308
- expect(s.peek()).toBe(0);
309
- expect(spy).not.toHaveBeenCalled();
310
- s.value = 3;
311
- expect(spy).toHaveBeenCalledWith(3);
312
- expect(s.peek()).toBe(3);
313
- setEffectErrorHandler(prev);
314
- });
315
-
316
- it("equals throw during batch doesn't corrupt batchDepth", () => {
317
- const s = signal(0, {
318
- equals: (_a, b) => {
319
- if (b === 99) {
320
- throw new Error("eq-batch");
321
- }
322
- return false;
323
- },
324
- });
325
- const spy = vi.fn();
326
- const prev = setEffectErrorHandler(() => {
327
- // swallow
328
- });
329
- effect(() => {
330
- spy(s.value);
331
- return undefined;
332
- });
333
- spy.mockClear();
334
- expect(() =>
335
- batch(() => {
336
- s.value = 99;
337
- }),
338
- ).toThrow("eq-batch");
339
- spy.mockClear();
340
- s.value = 1;
341
- expect(spy).toHaveBeenCalledWith(1);
342
- setEffectErrorHandler(prev);
343
- });
344
- });
345
-
346
- // ---------------------------------------------------------------------------
347
- // 6. Dispose inside error handler
348
- // ---------------------------------------------------------------------------
349
- describe("dispose inside error handler", () => {
350
- it("handler disposes the throwing effect — no zombie", () => {
351
- const s = signal(0);
352
- // eslint-disable-next-line prefer-const
353
- let dispose: (() => void) | undefined;
354
- const spy = vi.fn();
355
- const prev = setEffectErrorHandler(() => {
356
- dispose?.();
357
- });
358
- dispose = effect(() => {
359
- spy(s.value);
360
- if (s.value > 0) {
361
- throw new Error("self-destruct");
362
- }
363
- return undefined;
364
- });
365
- spy.mockClear();
366
- s.value = 1;
367
- expect(spy).toHaveBeenCalledWith(1);
368
- spy.mockClear();
369
- s.value = 2;
370
- expect(spy).not.toHaveBeenCalled();
371
- setEffectErrorHandler(prev);
372
- });
373
-
374
- it("handler disposes another effect during flush", () => {
375
- const s = signal(0);
376
- // eslint-disable-next-line prefer-const
377
- let disposeB: (() => void) | undefined;
378
- const spyA = vi.fn();
379
- const spyB = vi.fn();
380
- const prev = setEffectErrorHandler(() => {
381
- disposeB?.();
382
- });
383
- effect(() => {
384
- spyA(s.value);
385
- if (s.value > 0) {
386
- throw new Error("A-throws");
387
- }
388
- return undefined;
389
- });
390
- disposeB = effect(() => {
391
- spyB(s.value);
392
- return undefined;
393
- });
394
- spyA.mockClear();
395
- spyB.mockClear();
396
- s.value = 1;
397
- spyA.mockClear();
398
- spyB.mockClear();
399
- s.value = 2;
400
- expect(spyA).toHaveBeenCalledWith(2);
401
- expect(spyB).not.toHaveBeenCalled();
402
- setEffectErrorHandler(prev);
403
- });
404
- });
405
-
406
- // ---------------------------------------------------------------------------
407
- // 7. Diamond with dual throwing computeds
408
- // ---------------------------------------------------------------------------
409
- describe("diamond with dual throwing computeds", () => {
410
- it("both branches throw — effect sees errors from both", () => {
411
- const prev = setEffectErrorHandler(() => {
412
- // swallow
413
- });
414
- const root = signal(0);
415
- const left = computed(() => {
416
- if (root.value > 0) {
417
- throw new Error("left-boom");
418
- }
419
- return root.value;
420
- });
421
- const right = computed(() => {
422
- if (root.value > 0) {
423
- throw new Error("right-boom");
424
- }
425
- return root.value;
426
- });
427
- const log: string[] = [];
428
- effect(() => {
429
- const errs: string[] = [];
430
- try {
431
- void left.value;
432
- } catch (e) {
433
- errs.push((e as Error).message);
434
- }
435
- try {
436
- void right.value;
437
- } catch (e) {
438
- errs.push((e as Error).message);
439
- }
440
- log.push(errs.join(",") || "ok");
441
- return undefined;
442
- });
443
- log.length = 0;
444
- root.value = 1;
445
- expect(log.some((entry) => entry.includes("left-boom") && entry.includes("right-boom"))).toBe(
446
- true,
447
- );
448
- // Recovery
449
- log.length = 0;
450
- root.value = -1;
451
- expect(log.some((entry) => entry === "ok")).toBe(true);
452
- setEffectErrorHandler(prev);
453
- });
454
- });
455
-
456
- // ---------------------------------------------------------------------------
457
- // 8. Nested batch where inner batch callback throws
458
- // ---------------------------------------------------------------------------
459
- describe("nested batch inner throw", () => {
460
- it("inner batch throws — outer batch still flushes", () => {
461
- const prev = setEffectErrorHandler(() => {
462
- // swallow
463
- });
464
- const s = signal(0);
465
- const spy = vi.fn();
466
- effect(() => {
467
- spy(s.value);
468
- return undefined;
469
- });
470
- spy.mockClear();
471
- expect(() => {
472
- batch(() => {
473
- s.value = 10;
474
- batch(() => {
475
- throw new Error("inner-batch-throw");
476
- });
477
- });
478
- }).toThrow("inner-batch-throw");
479
- spy.mockClear();
480
- s.value = 20;
481
- expect(spy).toHaveBeenCalledWith(20);
482
- setEffectErrorHandler(prev);
483
- });
484
-
485
- it("outer batch throws after inner batch — batchDepth correct", () => {
486
- const prev = setEffectErrorHandler(() => {
487
- // swallow
488
- });
489
- const s = signal(0);
490
- const spy = vi.fn();
491
- effect(() => {
492
- spy(s.value);
493
- return undefined;
494
- });
495
- spy.mockClear();
496
- expect(() => {
497
- batch(() => {
498
- batch(() => {
499
- s.value = 5;
500
- });
501
- throw new Error("outer-throw");
502
- });
503
- }).toThrow("outer-throw");
504
- spy.mockClear();
505
- s.value = 6;
506
- expect(spy).toHaveBeenCalledWith(6);
507
- setEffectErrorHandler(prev);
508
- });
509
- });
510
-
511
- // ---------------------------------------------------------------------------
512
- // 9. Effect body writes to signal that triggers other effects — one throws
513
- // ---------------------------------------------------------------------------
514
- describe("effect body writes trigger cascade with throw", () => {
515
- it("effect A writes to s2, effect B on s2 throws — A still re-subscribes", () => {
516
- const prev = setEffectErrorHandler(() => {
517
- // swallow
518
- });
519
- const s1 = signal(0);
520
- const s2 = signal(0);
521
- const spyA = vi.fn();
522
- effect(() => {
523
- spyA(s1.value);
524
- s2.value = s1.value * 10;
525
- return undefined;
526
- });
527
- effect(() => {
528
- if (s2.value > 0) {
529
- throw new Error("B-throws");
530
- }
531
- return undefined;
532
- });
533
- spyA.mockClear();
534
- s1.value = 1;
535
- spyA.mockClear();
536
- s1.value = 2;
537
- expect(spyA).toHaveBeenCalledWith(2);
538
- setEffectErrorHandler(prev);
539
- });
540
- });
541
-
542
- // ---------------------------------------------------------------------------
543
- // 10. Verify flushing flag is never stuck (direct invariant check)
544
- // ---------------------------------------------------------------------------
545
- describe("flushing invariant is never stuck", () => {
546
- it("after many throws, flushing is false (flushSync works)", () => {
547
- const prev = setEffectErrorHandler(() => {
548
- // swallow
549
- });
550
- const s = signal(0);
551
- for (let i = 0; i < 50; i++) {
552
- effect(() => {
553
- if (s.value > 0) {
554
- throw new Error(`stuck-${i}`);
555
- }
556
- return undefined;
557
- });
558
- }
559
- s.value = 1;
560
- const spy = vi.fn();
561
- effect(() => {
562
- spy(s.value);
563
- return undefined;
564
- });
565
- spy.mockClear();
566
- s.value = 2;
567
- expect(spy).toHaveBeenCalledWith(2);
568
- setEffectErrorHandler(prev);
569
- });
570
-
571
- it("batch with only throwing effects — batchDepth returns to 0", () => {
572
- const prev = setEffectErrorHandler(() => {
573
- // swallow
574
- });
575
- const s = signal(0);
576
- effect(() => {
577
- if (s.value > 0) {
578
- throw new Error("batch-stuck");
579
- }
580
- return undefined;
581
- });
582
- batch(() => {
583
- s.value = 1;
584
- });
585
- const spy = vi.fn();
586
- effect(() => {
587
- spy(s.value);
588
- return undefined;
589
- });
590
- spy.mockClear();
591
- s.value = 99;
592
- expect(spy).toHaveBeenCalledWith(99);
593
- setEffectErrorHandler(prev);
594
- });
595
- });
596
-
597
- // ---------------------------------------------------------------------------
598
- // 11. Computed cycle detection doesn't corrupt state
599
- // ---------------------------------------------------------------------------
600
- describe("computed cycle detection", () => {
601
- it("cycle throws on access but doesn't leave system broken", () => {
602
- // eslint-disable-next-line prefer-const
603
- let c2: { readonly value: number };
604
- const c1 = computed(() => (c2 as { readonly value: number }).value + 1);
605
- c2 = computed(() => c1.value + 1);
606
- expect(() => c1.value).toThrow("Cycle detected");
607
- const s = signal(42);
608
- const spy = vi.fn();
609
- const prev = setEffectErrorHandler(() => {
610
- // swallow
611
- });
612
- const d = effect(() => {
613
- spy(s.value);
614
- return undefined;
615
- });
616
- spy.mockClear();
617
- s.value = 100;
618
- expect(spy).toHaveBeenCalledWith(100);
619
- d();
620
- setEffectErrorHandler(prev);
621
- });
622
- });
623
-
624
- // ---------------------------------------------------------------------------
625
- // 12. Cleanup writes to signal (re-entrant during flush)
626
- // ---------------------------------------------------------------------------
627
- describe("cleanup writes to signal during flush", () => {
628
- it("cleanup writes to a signal — downstream effect picks it up", () => {
629
- const prev = setEffectErrorHandler(() => {
630
- // swallow
631
- });
632
- const s = signal(0);
633
- const s2 = signal("x");
634
- const log: string[] = [];
635
- effect(() => {
636
- void s.value;
637
- return () => {
638
- s2.value = `cleaned-${s.peek()}`;
639
- };
640
- });
641
- effect(() => {
642
- log.push(s2.value);
643
- return undefined;
644
- });
645
- log.length = 0;
646
- s.value = 1;
647
- expect(log).toContain("cleaned-1");
648
- setEffectErrorHandler(prev);
649
- });
650
- });