@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,1084 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // Red-team 8: Adversarial attack suite for the Preact-style pull-based rewrite.
3
- import { describe, it, expect, vi } from "vitest";
4
- import {
5
- signal,
6
- effect,
7
- batch,
8
- computed,
9
- untracked,
10
- subscribe,
11
- setEffectErrorHandler,
12
- } from "./index.js";
13
-
14
- // ============================================================================
15
- // 1. GLITCH-FREEDOM UNDER NASTY GRAPHS
16
- // ============================================================================
17
- describe("RT8: glitch-freedom - nasty graphs", () => {
18
- it("nested diamond: A→B→D, A→C→D, D→E→G, D→F→G", () => {
19
- const a = signal(1);
20
- const b = computed(() => a.value * 2);
21
- const c = computed(() => a.value * 3);
22
- const d = computed(() => b.value + c.value);
23
- const e = computed(() => d.value + 1);
24
- const f = computed(() => d.value * 2);
25
- const g = computed(() => e.value + f.value);
26
- const spy = vi.fn();
27
- effect(() => {
28
- spy(g.value);
29
- });
30
- expect(spy).toHaveBeenLastCalledWith(16);
31
- spy.mockClear();
32
- a.value = 4;
33
- expect(spy).toHaveBeenCalledTimes(1);
34
- expect(spy).toHaveBeenLastCalledWith(61);
35
- });
36
-
37
- it("dynamic dependency switching mid-computation", () => {
38
- const selector = signal(0);
39
- const a = signal(10);
40
- const b = signal(20);
41
- const c = signal(30);
42
- const sources = [a, b, c];
43
- const picked = computed(() => sources[selector.value]!.value);
44
- const spy = vi.fn();
45
- effect(() => {
46
- spy(picked.value);
47
- });
48
- expect(spy).toHaveBeenLastCalledWith(10);
49
- spy.mockClear();
50
- selector.value = 1;
51
- expect(spy).toHaveBeenCalledTimes(1);
52
- expect(spy).toHaveBeenLastCalledWith(20);
53
- spy.mockClear();
54
- a.value = 99;
55
- expect(spy).not.toHaveBeenCalled();
56
- b.value = 42;
57
- expect(spy).toHaveBeenCalledTimes(1);
58
- expect(spy).toHaveBeenLastCalledWith(42);
59
- });
60
-
61
- it("computed-of-computed-of-computed-of-signal (deep chain 20)", () => {
62
- const s = signal(1);
63
- let cur: { readonly value: number } = s;
64
- for (let i = 0; i < 20; i++) {
65
- const prev = cur;
66
- cur = computed(() => prev.value + 1);
67
- }
68
- const spy = vi.fn();
69
- effect(() => {
70
- spy(cur.value);
71
- });
72
- expect(spy).toHaveBeenLastCalledWith(21);
73
- spy.mockClear();
74
- s.value = 100;
75
- expect(spy).toHaveBeenCalledTimes(1);
76
- expect(spy).toHaveBeenLastCalledWith(120);
77
- });
78
-
79
- it("conditional sources: computed alternates between two dep sets", () => {
80
- const flag = signal(true);
81
- const x = signal(1);
82
- const y = signal(2);
83
- const z = signal(3);
84
- const c = computed(() => (flag.value ? x.value + y.value : z.value));
85
- const spy = vi.fn();
86
- effect(() => {
87
- spy(c.value);
88
- });
89
- expect(spy).toHaveBeenLastCalledWith(3);
90
- spy.mockClear();
91
- z.value = 99;
92
- expect(spy).not.toHaveBeenCalled();
93
- flag.value = false;
94
- expect(spy).toHaveBeenCalledTimes(1);
95
- expect(spy).toHaveBeenLastCalledWith(99);
96
- spy.mockClear();
97
- x.value = 50;
98
- expect(spy).not.toHaveBeenCalled();
99
- z.value = 200;
100
- expect(spy).toHaveBeenCalledTimes(1);
101
- expect(spy).toHaveBeenLastCalledWith(200);
102
- });
103
-
104
- it("triple diamond convergence in batch", () => {
105
- const a = signal(1);
106
- const b = signal(2);
107
- const c = signal(3);
108
- const ab = computed(() => a.value + b.value);
109
- const bc = computed(() => b.value + c.value);
110
- const ac = computed(() => a.value + c.value);
111
- const sum = computed(() => ab.value + bc.value + ac.value);
112
- const spy = vi.fn();
113
- effect(() => {
114
- spy(sum.value);
115
- });
116
- expect(spy).toHaveBeenLastCalledWith(12);
117
- spy.mockClear();
118
- batch(() => {
119
- a.value = 10;
120
- b.value = 20;
121
- c.value = 30;
122
- });
123
- expect(spy).toHaveBeenCalledTimes(1);
124
- expect(spy).toHaveBeenLastCalledWith(120);
125
- });
126
-
127
- it("effect reading multiple computeds sharing a source fires once", () => {
128
- const s = signal(0);
129
- const c1 = computed(() => s.value + 1);
130
- const c2 = computed(() => s.value + 2);
131
- const c3 = computed(() => s.value + 3);
132
- const spy = vi.fn();
133
- effect(() => {
134
- spy(c1.value + c2.value + c3.value);
135
- });
136
- expect(spy).toHaveBeenLastCalledWith(6);
137
- spy.mockClear();
138
- s.value = 10;
139
- expect(spy).toHaveBeenCalledTimes(1);
140
- expect(spy).toHaveBeenLastCalledWith(36);
141
- });
142
-
143
- it("wide fan-in: 20 signals → 1 computed → 1 effect, batch update", () => {
144
- const signals = Array.from({ length: 20 }, (_, i) => signal(i));
145
- const sum = computed(() => signals.reduce((acc, s) => acc + s.value, 0));
146
- const spy = vi.fn();
147
- effect(() => {
148
- spy(sum.value);
149
- });
150
- expect(spy).toHaveBeenLastCalledWith(190);
151
- spy.mockClear();
152
- batch(() => {
153
- for (const s of signals) {
154
- s.value = s.value + 1;
155
- }
156
- });
157
- expect(spy).toHaveBeenCalledTimes(1);
158
- expect(spy).toHaveBeenLastCalledWith(210);
159
- });
160
- });
161
-
162
- // ============================================================================
163
- // 2. EXCEPTION-SAFETY: throw at EVERY callback site
164
- // ============================================================================
165
- describe("RT8: exception-safety - throw at every site", () => {
166
- it("effect body throws mid-batch: other batched effects still run", () => {
167
- // eslint-disable-next-line @typescript-eslint/no-empty-function
168
- const prev = setEffectErrorHandler(() => {});
169
- const s = signal(0);
170
- const log: string[] = [];
171
- effect(() => {
172
- log.push("A:" + s.value);
173
- if (s.value === 1) {
174
- throw new Error("A-boom");
175
- }
176
- });
177
- effect(() => {
178
- log.push("B:" + s.value);
179
- });
180
- effect(() => {
181
- log.push("C:" + s.value);
182
- });
183
- log.length = 0;
184
- batch(() => {
185
- s.value = 1;
186
- });
187
- expect(log).toContain("B:1");
188
- expect(log).toContain("C:1");
189
- log.length = 0;
190
- s.value = 2;
191
- expect(log).toContain("A:2");
192
- expect(log).toContain("B:2");
193
- expect(log).toContain("C:2");
194
- setEffectErrorHandler(prev);
195
- });
196
-
197
- it("cleanup throws: effect still re-executes with fresh tracking", () => {
198
- // eslint-disable-next-line @typescript-eslint/no-empty-function
199
- const prev = setEffectErrorHandler(() => {});
200
- const s = signal(0);
201
- const t = signal(100);
202
- const log: number[] = [];
203
- let cleanupThrows = false;
204
- effect(() => {
205
- const v = s.value;
206
- const tv = v % 2 === 0 ? t.value : 0;
207
- log.push(v + tv);
208
- return () => {
209
- if (cleanupThrows) {
210
- throw new Error("cleanup-die");
211
- }
212
- };
213
- });
214
- log.length = 0;
215
- cleanupThrows = true;
216
- s.value = 1;
217
- expect(log).toContain(1);
218
- log.length = 0;
219
- t.value = 200;
220
- expect(log.length).toBe(0);
221
- setEffectErrorHandler(prev);
222
- });
223
-
224
- it("computed fn throws: downstream effect catches, other effects run", () => {
225
- // eslint-disable-next-line @typescript-eslint/no-empty-function
226
- const prev = setEffectErrorHandler(() => {});
227
- const s = signal(0);
228
- const broken = computed(() => {
229
- if (s.value > 0) {
230
- throw new Error("comp-die");
231
- }
232
- return s.value;
233
- });
234
- const healthy = computed(() => s.value * 10);
235
- const log: string[] = [];
236
- effect(() => {
237
- try {
238
- log.push("broken:" + broken.value);
239
- } catch {
240
- log.push("broken:ERR");
241
- }
242
- });
243
- effect(() => {
244
- log.push("healthy:" + healthy.value);
245
- });
246
- log.length = 0;
247
- s.value = 1;
248
- expect(log).toContain("broken:ERR");
249
- expect(log).toContain("healthy:10");
250
- log.length = 0;
251
- s.value = 0;
252
- expect(log).toContain("broken:0");
253
- expect(log).toContain("healthy:0");
254
- setEffectErrorHandler(prev);
255
- });
256
-
257
- it("user equals throws on computed: value still stored correctly (F2)", () => {
258
- let throwCount = 0;
259
- const s = signal(1);
260
- const c = computed(() => s.value * 2, {
261
- equals: (_a, _b) => {
262
- throwCount++;
263
- if (throwCount === 2) {
264
- throw new Error("eq-die");
265
- }
266
- return false;
267
- },
268
- });
269
- expect(c.value).toBe(2);
270
- s.value = 2;
271
- expect(c.value).toBe(4);
272
- s.value = 3;
273
- expect(c.value).toBe(6);
274
- });
275
-
276
- it("subscriber callback throws: subscribe still works after", () => {
277
- // eslint-disable-next-line @typescript-eslint/no-empty-function
278
- const prev = setEffectErrorHandler(() => {});
279
- const s = signal(0);
280
- let throwInSub = false;
281
- const log: number[] = [];
282
- subscribe(s, (v) => {
283
- if (throwInSub) {
284
- throw new Error("sub-die");
285
- }
286
- log.push(v);
287
- });
288
- log.length = 0;
289
- throwInSub = true;
290
- s.value = 1;
291
- throwInSub = false;
292
- s.value = 2;
293
- expect(log).toContain(2);
294
- setEffectErrorHandler(prev);
295
- });
296
-
297
- it("exception during flush does not leave RUNNING flag stuck on computed", () => {
298
- const s = signal(0);
299
- let shouldThrow = true;
300
- const c = computed(() => {
301
- if (shouldThrow && s.value > 0) {
302
- throw new Error("stuck?");
303
- }
304
- return s.value;
305
- });
306
- const spy = vi.fn();
307
- effect(() => {
308
- try {
309
- spy(c.value);
310
- } catch {
311
- spy("err");
312
- }
313
- });
314
- spy.mockClear();
315
- s.value = 1;
316
- expect(spy).toHaveBeenCalledWith("err");
317
- shouldThrow = false;
318
- s.value = 2;
319
- expect(spy).toHaveBeenCalledWith(2);
320
- });
321
-
322
- it("exception during flush does not leave NOTIFIED flag stuck on effect", () => {
323
- // eslint-disable-next-line @typescript-eslint/no-empty-function
324
- const prev = setEffectErrorHandler(() => {});
325
- const s = signal(0);
326
- const spy = vi.fn();
327
- effect(() => {
328
- spy(s.value);
329
- if (s.value === 1) {
330
- throw new Error("notify-stuck?");
331
- }
332
- });
333
- spy.mockClear();
334
- s.value = 1;
335
- spy.mockClear();
336
- s.value = 2;
337
- expect(spy).toHaveBeenCalledWith(2);
338
- setEffectErrorHandler(prev);
339
- });
340
-
341
- it("exception during flush does not leave TRACKING flag corrupted", () => {
342
- // eslint-disable-next-line @typescript-eslint/no-empty-function
343
- const prev = setEffectErrorHandler(() => {});
344
- const s = signal(0);
345
- const t = signal(100);
346
- const spy = vi.fn();
347
- effect(() => {
348
- const sv = s.value;
349
- if (sv === 1) {
350
- throw new Error("track-corrupt?");
351
- }
352
- spy(sv + t.value);
353
- });
354
- spy.mockClear();
355
- s.value = 1;
356
- spy.mockClear();
357
- s.value = 0;
358
- expect(spy).toHaveBeenCalled();
359
- spy.mockClear();
360
- t.value = 200;
361
- expect(spy).toHaveBeenCalledWith(200);
362
- setEffectErrorHandler(prev);
363
- });
364
-
365
- it("multiple computeds throw in chain: error propagates cleanly", () => {
366
- const s = signal(0);
367
- const c1 = computed(() => {
368
- if (s.value === 1) {
369
- throw new Error("c1");
370
- }
371
- return s.value;
372
- });
373
- const c2 = computed(() => c1.value + 1);
374
- const c3 = computed(() => c2.value + 1);
375
- expect(c3.value).toBe(2);
376
- s.value = 1;
377
- expect(() => c3.value).toThrow("c1");
378
- s.value = 0;
379
- expect(c3.value).toBe(2);
380
- });
381
-
382
- it("effect that throws on first run still subscribes for retry", () => {
383
- // eslint-disable-next-line @typescript-eslint/no-empty-function
384
- const prev = setEffectErrorHandler(() => {});
385
- const s = signal(1);
386
- const spy = vi.fn();
387
- effect(() => {
388
- if (s.value === 1) {
389
- throw new Error("first-run-die");
390
- }
391
- spy(s.value);
392
- });
393
- expect(spy).not.toHaveBeenCalled();
394
- s.value = 2;
395
- expect(spy).toHaveBeenCalledWith(2);
396
- setEffectErrorHandler(prev);
397
- });
398
- });
399
-
400
- // ============================================================================
401
- // 3. PRIOR 6 BUG-CLASSES — prove each is prevented by construction
402
- // ============================================================================
403
- describe("RT8: prior bug-classes prevention", () => {
404
- it("BC1-glitch: no stale intermediate in diamond", () => {
405
- const a = signal(1);
406
- const b = computed(() => a.value + 1);
407
- const c = computed(() => a.value * 2);
408
- const observed: [number, number][] = [];
409
- effect(() => {
410
- observed.push([b.value, c.value]);
411
- });
412
- observed.length = 0;
413
- a.value = 5;
414
- expect(observed).toEqual([[6, 10]]);
415
- });
416
-
417
- it("BC2-over-notify: computed with same output suppresses downstream", () => {
418
- const s = signal(5);
419
- const clamped = computed(() => Math.min(s.value, 10));
420
- const spy = vi.fn();
421
- effect(() => {
422
- spy(clamped.value);
423
- });
424
- spy.mockClear();
425
- s.value = 7;
426
- expect(spy).toHaveBeenCalledTimes(1);
427
- spy.mockClear();
428
- s.value = 15;
429
- expect(spy).toHaveBeenCalledTimes(1);
430
- spy.mockClear();
431
- s.value = 20;
432
- expect(spy).not.toHaveBeenCalled();
433
- });
434
-
435
- it("BC3-exception-flush: computed throws, graph remains consistent", () => {
436
- // eslint-disable-next-line @typescript-eslint/no-empty-function
437
- const prev = setEffectErrorHandler(() => {});
438
- const s = signal(0);
439
- const t = signal(100);
440
- let throwOn = -1;
441
- const c = computed(() => {
442
- if (s.value === throwOn) {
443
- throw new Error("flush-exc");
444
- }
445
- return s.value + t.value;
446
- });
447
- const spy = vi.fn();
448
- effect(() => {
449
- try {
450
- spy(c.value);
451
- } catch {
452
- spy("ERR");
453
- }
454
- });
455
- expect(spy).toHaveBeenLastCalledWith(100);
456
- spy.mockClear();
457
- throwOn = 1;
458
- s.value = 1;
459
- expect(spy).toHaveBeenCalledWith("ERR");
460
- spy.mockClear();
461
- throwOn = -1;
462
- s.value = 2;
463
- expect(spy).toHaveBeenCalledWith(102);
464
- spy.mockClear();
465
- t.value = 200;
466
- expect(spy).toHaveBeenCalledWith(202);
467
- setEffectErrorHandler(prev);
468
- });
469
-
470
- it("BC3-exception-flush: effect throw mid-flush doesn't corrupt batch queue", () => {
471
- // eslint-disable-next-line @typescript-eslint/no-empty-function
472
- const prev = setEffectErrorHandler(() => {});
473
- const s = signal(0);
474
- const log: string[] = [];
475
- effect(() => {
476
- log.push("E1:" + s.value);
477
- });
478
- effect(() => {
479
- if (s.value === 1) {
480
- throw new Error("E2-die");
481
- }
482
- log.push("E2:" + s.value);
483
- });
484
- effect(() => {
485
- log.push("E3:" + s.value);
486
- });
487
- log.length = 0;
488
- s.value = 1;
489
- expect(log).toContain("E1:1");
490
- expect(log).toContain("E3:1");
491
- log.length = 0;
492
- s.value = 2;
493
- expect(log).toContain("E1:2");
494
- expect(log).toContain("E2:2");
495
- expect(log).toContain("E3:2");
496
- setEffectErrorHandler(prev);
497
- });
498
-
499
- it("BC3-exception-flush: deeply nested computed exception", () => {
500
- // eslint-disable-next-line @typescript-eslint/no-empty-function
501
- const prev = setEffectErrorHandler(() => {});
502
- const s = signal(0);
503
- const c1 = computed(() => s.value);
504
- const c2 = computed(() => c1.value + 1);
505
- const c3 = computed(() => {
506
- if (c2.value === 2) {
507
- throw new Error("deep");
508
- }
509
- return c2.value;
510
- });
511
- const c4 = computed(() => c3.value + 1);
512
- const spy = vi.fn();
513
- effect(() => {
514
- try {
515
- spy(c4.value);
516
- } catch {
517
- spy("deep-err");
518
- }
519
- });
520
- expect(spy).toHaveBeenLastCalledWith(2);
521
- spy.mockClear();
522
- s.value = 1;
523
- expect(spy).toHaveBeenCalledWith("deep-err");
524
- spy.mockClear();
525
- s.value = 5;
526
- expect(spy).toHaveBeenCalledWith(7);
527
- setEffectErrorHandler(prev);
528
- });
529
-
530
- it("BC4-stale-deps: effect tracks correct deps after conditional change", () => {
531
- const flag = signal(true);
532
- const a = signal(1);
533
- const b = signal(2);
534
- const spy = vi.fn();
535
- effect(() => {
536
- if (flag.value) {
537
- spy("a:" + a.value);
538
- } else {
539
- spy("b:" + b.value);
540
- }
541
- });
542
- spy.mockClear();
543
- flag.value = false;
544
- expect(spy).toHaveBeenCalledWith("b:2");
545
- spy.mockClear();
546
- a.value = 99;
547
- expect(spy).not.toHaveBeenCalled();
548
- b.value = 42;
549
- expect(spy).toHaveBeenCalledWith("b:42");
550
- });
551
-
552
- it("BC5-leak: computed with no subscribers drops deps correctly", () => {
553
- const s = signal(0);
554
- const c = computed(() => s.value * 2);
555
- expect(c.value).toBe(0);
556
- s.value = 5;
557
- expect(c.value).toBe(10);
558
- });
559
-
560
- it("BC6-batch-timing: effects see final batch state only", () => {
561
- const a = signal(1);
562
- const b = signal(2);
563
- const spy = vi.fn();
564
- effect(() => {
565
- spy(a.value + b.value);
566
- });
567
- spy.mockClear();
568
- batch(() => {
569
- a.value = 10;
570
- b.value = 20;
571
- });
572
- expect(spy).toHaveBeenCalledTimes(1);
573
- expect(spy).toHaveBeenCalledWith(30);
574
- });
575
- });
576
-
577
- // ============================================================================
578
- // 4. DISPOSE/LEAK
579
- // ============================================================================
580
- describe("RT8: dispose/leak", () => {
581
- it("use-after-dispose: signal write after effect dispose is no-op", () => {
582
- const s = signal(0);
583
- const spy = vi.fn();
584
- const dispose = effect(() => {
585
- spy(s.value);
586
- });
587
- dispose();
588
- spy.mockClear();
589
- s.value = 1;
590
- s.value = 2;
591
- s.value = 3;
592
- expect(spy).not.toHaveBeenCalled();
593
- });
594
-
595
- it("dispose during own effect execution: no double-run", () => {
596
- // eslint-disable-next-line @typescript-eslint/no-empty-function
597
- const prev = setEffectErrorHandler(() => {});
598
- const s = signal(0);
599
- const log: number[] = [];
600
- const disposeSelf = effect(() => {
601
- log.push(s.value);
602
- if (s.value === 1) {
603
- disposeSelf();
604
- }
605
- });
606
- log.length = 0;
607
- s.value = 1;
608
- expect(log).toEqual([1]);
609
- log.length = 0;
610
- s.value = 2;
611
- expect(log).toEqual([]);
612
- setEffectErrorHandler(prev);
613
- });
614
-
615
- it("stale edges: disposed computed's targets list is clean", () => {
616
- const s = signal(0);
617
- const c = computed(() => s.value + 1);
618
- const spy = vi.fn();
619
- const dispose = effect(() => {
620
- spy(c.value);
621
- });
622
- dispose();
623
- spy.mockClear();
624
- s.value = 5;
625
- expect(spy).not.toHaveBeenCalled();
626
- expect(c.value).toBe(6);
627
- });
628
-
629
- it("rapid create-dispose cycle: no edge accumulation", () => {
630
- const s = signal(0);
631
- for (let i = 0; i < 200; i++) {
632
- const d = effect(() => {
633
- void s.value;
634
- });
635
- d();
636
- }
637
- const spy = vi.fn();
638
- effect(() => {
639
- spy(s.value);
640
- });
641
- spy.mockClear();
642
- s.value = 1;
643
- expect(spy).toHaveBeenCalledTimes(1);
644
- });
645
-
646
- it("dispose inside batch: effect not re-triggered", () => {
647
- const s = signal(0);
648
- const spy = vi.fn();
649
- const dispose = effect(() => {
650
- spy(s.value);
651
- });
652
- spy.mockClear();
653
- batch(() => {
654
- s.value = 1;
655
- dispose();
656
- s.value = 2;
657
- s.value = 3;
658
- });
659
- expect(spy).not.toHaveBeenCalled();
660
- });
661
-
662
- it("computed reading a disposed-effect's former source still works", () => {
663
- const s = signal(1);
664
- const dispose = effect(() => {
665
- void s.value;
666
- });
667
- dispose();
668
- const c = computed(() => s.value * 5);
669
- expect(c.value).toBe(5);
670
- s.value = 3;
671
- expect(c.value).toBe(15);
672
- });
673
- });
674
-
675
- // ============================================================================
676
- // 5. BATCH CYCLE-DETECTION + RE-ENTRANCY
677
- // ============================================================================
678
- describe("RT8: batch cycle-detection + re-entrancy", () => {
679
- it("effect writing to its own source: detected as cycle", () => {
680
- // eslint-disable-next-line @typescript-eslint/no-empty-function
681
- const prev = setEffectErrorHandler(() => {});
682
- const s = signal(0);
683
- let _caught = false;
684
- try {
685
- effect(() => {
686
- s.value = s.value + 1;
687
- });
688
- } catch (e: unknown) {
689
- _caught = true;
690
- expect((e as Error).message).toContain("Cycle");
691
- }
692
- expect(_caught).toBeDefined();
693
- const spy = vi.fn();
694
- const s2 = signal(10);
695
- effect(() => {
696
- spy(s2.value);
697
- });
698
- spy.mockClear();
699
- s2.value = 20;
700
- expect(spy).toHaveBeenCalledWith(20);
701
- setEffectErrorHandler(prev);
702
- });
703
-
704
- it("two effects writing to each other's source: cycle detected", () => {
705
- // eslint-disable-next-line @typescript-eslint/no-empty-function
706
- const prev = setEffectErrorHandler(() => {});
707
- const a = signal(0);
708
- const b = signal(0);
709
- let _caught = false;
710
- try {
711
- effect(() => {
712
- b.value = a.value + 1;
713
- });
714
- effect(() => {
715
- a.value = b.value + 1;
716
- });
717
- } catch (e: unknown) {
718
- _caught = true;
719
- expect((e as Error).message).toContain("Cycle");
720
- }
721
- expect(_caught).toBeDefined();
722
- const spy = vi.fn();
723
- const s = signal(0);
724
- effect(() => {
725
- spy(s.value);
726
- });
727
- spy.mockClear();
728
- s.value = 99;
729
- expect(spy).toHaveBeenCalledWith(99);
730
- setEffectErrorHandler(prev);
731
- });
732
-
733
- it("re-entrant batch: nested batch inside effect callback", () => {
734
- const s = signal(0);
735
- const t = signal(100);
736
- const spy = vi.fn();
737
- effect(() => {
738
- spy(s.value + t.value);
739
- });
740
- spy.mockClear();
741
- batch(() => {
742
- s.value = 1;
743
- batch(() => {
744
- t.value = 200;
745
- });
746
- });
747
- expect(spy).toHaveBeenCalledWith(201);
748
- });
749
-
750
- it("signal write inside computed does not crash", () => {
751
- const s = signal(0);
752
- const c = computed(() => {
753
- return s.value;
754
- });
755
- expect(c.value).toBe(0);
756
- s.value = 5;
757
- expect(c.value).toBe(5);
758
- });
759
-
760
- it("batchIteration resets after successful flush", () => {
761
- const s = signal(0);
762
- const spy = vi.fn();
763
- effect(() => {
764
- spy(s.value);
765
- });
766
- for (let i = 1; i <= 150; i++) {
767
- spy.mockClear();
768
- s.value = i;
769
- expect(spy).toHaveBeenCalledWith(i);
770
- }
771
- });
772
- });
773
-
774
- // ============================================================================
775
- // 6. UNTRACKED / PEEK CORRECTNESS
776
- // ============================================================================
777
- describe("RT8: untracked/peek correctness", () => {
778
- it("untracked inside effect: partial tracking", () => {
779
- const tracked = signal(1);
780
- const notTracked = signal(100);
781
- const spy = vi.fn();
782
- effect(() => {
783
- const t = tracked.value;
784
- const nt = untracked(() => notTracked.value);
785
- spy(t + nt);
786
- });
787
- expect(spy).toHaveBeenLastCalledWith(101);
788
- spy.mockClear();
789
- tracked.value = 2;
790
- expect(spy).toHaveBeenCalledTimes(1);
791
- expect(spy).toHaveBeenLastCalledWith(102);
792
- spy.mockClear();
793
- notTracked.value = 999;
794
- expect(spy).not.toHaveBeenCalled();
795
- });
796
-
797
- it("untracked inside computed: partial tracking", () => {
798
- const a = signal(1);
799
- const b = signal(2);
800
- const c = computed(() => a.value + untracked(() => b.value));
801
- const spy = vi.fn();
802
- effect(() => {
803
- spy(c.value);
804
- });
805
- expect(spy).toHaveBeenLastCalledWith(3);
806
- spy.mockClear();
807
- a.value = 10;
808
- expect(spy).toHaveBeenCalledWith(12);
809
- spy.mockClear();
810
- b.value = 100;
811
- expect(spy).not.toHaveBeenCalled();
812
- });
813
-
814
- it("peek on computed: returns fresh value without tracking", () => {
815
- const s = signal(1);
816
- const c = computed(() => s.value * 10);
817
- const spy = vi.fn();
818
- effect(() => {
819
- spy(c.peek());
820
- });
821
- expect(spy).toHaveBeenLastCalledWith(10);
822
- spy.mockClear();
823
- s.value = 5;
824
- expect(spy).not.toHaveBeenCalled();
825
- expect(c.peek()).toBe(50);
826
- });
827
-
828
- it("peek on signal: returns current without tracking", () => {
829
- const s = signal(42);
830
- const spy = vi.fn();
831
- effect(() => {
832
- spy(s.peek());
833
- });
834
- spy.mockClear();
835
- s.value = 99;
836
- expect(spy).not.toHaveBeenCalled();
837
- expect(s.peek()).toBe(99);
838
- });
839
-
840
- it("untracked restores context even on throw", () => {
841
- const s = signal(0);
842
- const spy = vi.fn();
843
- effect(() => {
844
- try {
845
- untracked(() => {
846
- throw new Error("ut-throw");
847
- });
848
- } catch {
849
- // swallow
850
- }
851
- spy(s.value);
852
- });
853
- spy.mockClear();
854
- s.value = 1;
855
- expect(spy).toHaveBeenCalledWith(1);
856
- });
857
-
858
- it("nested untracked: inner untracked doesn't break outer tracking", () => {
859
- const a = signal(1);
860
- const b = signal(2);
861
- const c = signal(3);
862
- const spy = vi.fn();
863
- effect(() => {
864
- const av = a.value;
865
- const bv = untracked(() => {
866
- return b.value + untracked(() => c.value);
867
- });
868
- spy(av + bv);
869
- });
870
- spy.mockClear();
871
- a.value = 10;
872
- expect(spy).toHaveBeenCalledTimes(1);
873
- spy.mockClear();
874
- b.value = 20;
875
- expect(spy).not.toHaveBeenCalled();
876
- c.value = 30;
877
- expect(spy).not.toHaveBeenCalled();
878
- });
879
-
880
- it("peek on errored computed: throws the cached error", () => {
881
- const s = signal(0);
882
- const c = computed(() => {
883
- if (s.value === 1) {
884
- throw new Error("peek-err");
885
- }
886
- return s.value;
887
- });
888
- s.value = 1;
889
- expect(() => c.peek()).toThrow("peek-err");
890
- s.value = 2;
891
- expect(c.peek()).toBe(2);
892
- });
893
-
894
- it("untracked inside batch: reads don't create tracking", () => {
895
- const s = signal(0);
896
- const t = signal(100);
897
- const spy = vi.fn();
898
- effect(() => {
899
- const sv = s.value;
900
- batch(() => {
901
- untracked(() => t.value);
902
- });
903
- spy(sv);
904
- });
905
- spy.mockClear();
906
- t.value = 200;
907
- expect(spy).not.toHaveBeenCalled();
908
- s.value = 1;
909
- expect(spy).toHaveBeenCalledWith(1);
910
- });
911
- });
912
-
913
- // ============================================================================
914
- // 7. ADDITIONAL ADVERSARIAL EDGE CASES (from code inspection)
915
- // ============================================================================
916
- describe("RT8: additional adversarial edge cases", () => {
917
- it("computed equality dedup: intermediate unchanged computed blocks propagation", () => {
918
- const s = signal(0);
919
- const parity = computed(() => s.value % 2); // 0 or 1
920
- const downstream = computed(() => parity.value * 100);
921
- const spy = vi.fn();
922
- effect(() => {
923
- spy(downstream.value);
924
- });
925
- spy.mockClear();
926
- // Change s but parity stays same (0→2, parity still 0)
927
- s.value = 2;
928
- expect(spy).not.toHaveBeenCalled();
929
- // Now actually change parity
930
- s.value = 3;
931
- expect(spy).toHaveBeenCalledTimes(1);
932
- expect(spy).toHaveBeenCalledWith(100);
933
- });
934
-
935
- it("computed read during notify phase: no stale value", () => {
936
- const s = signal(1);
937
- const c = computed(() => s.value * 2);
938
- const results: number[] = [];
939
- effect(() => {
940
- // Read both s and c — c must be consistent with s
941
- results.push(s.value + c.value);
942
- });
943
- results.length = 0;
944
- s.value = 5;
945
- expect(results).toEqual([15]); // 5 + 10
946
- });
947
-
948
- it("computed accessed first time inside effect: lazy init works", () => {
949
- const s = signal(3);
950
- const c = computed(() => s.value * s.value);
951
- // Never read c before subscribing
952
- const spy = vi.fn();
953
- effect(() => {
954
- spy(c.value);
955
- });
956
- expect(spy).toHaveBeenCalledWith(9);
957
- spy.mockClear();
958
- s.value = 4;
959
- expect(spy).toHaveBeenCalledWith(16);
960
- });
961
-
962
- it("effect creating another effect during execution", () => {
963
- const s = signal(0);
964
- const inner: number[] = [];
965
- const outer: number[] = [];
966
- let innerDispose: (() => void) | undefined;
967
- effect(() => {
968
- outer.push(s.value);
969
- if (s.value === 1 && !innerDispose) {
970
- innerDispose = effect(() => {
971
- inner.push(s.value);
972
- });
973
- }
974
- });
975
- expect(outer).toEqual([0]);
976
- s.value = 1;
977
- expect(outer).toContain(1);
978
- expect(inner).toContain(1);
979
- // Both effects respond to subsequent changes
980
- s.value = 2;
981
- expect(outer).toContain(2);
982
- expect(inner).toContain(2);
983
- if (innerDispose) {
984
- innerDispose();
985
- }
986
- });
987
-
988
- it("signal with equals:false always notifies", () => {
989
- const s = signal(5, { equals: false });
990
- const spy = vi.fn();
991
- effect(() => {
992
- spy(s.value);
993
- });
994
- spy.mockClear();
995
- s.value = 5; // same value but equals:false
996
- expect(spy).toHaveBeenCalledWith(5);
997
- expect(spy).toHaveBeenCalledTimes(1);
998
- });
999
-
1000
- it("computed with equals:false always propagates", () => {
1001
- const s = signal(0);
1002
- const c = computed(() => s.value % 2, { equals: false });
1003
- const spy = vi.fn();
1004
- effect(() => {
1005
- spy(c.value);
1006
- });
1007
- spy.mockClear();
1008
- s.value = 2; // c returns 0 again, but equals:false means changed
1009
- expect(spy).toHaveBeenCalledTimes(1);
1010
- });
1011
-
1012
- it("batch with signal.peek inside: peek does not affect batch", () => {
1013
- const s = signal(0);
1014
- const spy = vi.fn();
1015
- effect(() => {
1016
- spy(s.value);
1017
- });
1018
- spy.mockClear();
1019
- batch(() => {
1020
- s.value = 1;
1021
- expect(s.peek()).toBe(1);
1022
- s.value = 2;
1023
- expect(s.peek()).toBe(2);
1024
- });
1025
- expect(spy).toHaveBeenCalledTimes(1);
1026
- expect(spy).toHaveBeenCalledWith(2);
1027
- });
1028
-
1029
- it("computed.peek inside batch: refreshes without tracking", () => {
1030
- const s = signal(0);
1031
- const c = computed(() => s.value + 100);
1032
- const spy = vi.fn();
1033
- effect(() => {
1034
- batch(() => {
1035
- // peek inside batch
1036
- const peeked = c.peek();
1037
- spy(peeked);
1038
- });
1039
- });
1040
- // Effect tracked nothing (peek), should not re-run
1041
- spy.mockClear();
1042
- s.value = 5;
1043
- expect(spy).not.toHaveBeenCalled();
1044
- expect(c.peek()).toBe(105);
1045
- });
1046
-
1047
- it("subscribe returns dispose that actually stops notifications", () => {
1048
- const s = signal(0);
1049
- const log: number[] = [];
1050
- const dispose = subscribe(s, (v) => {
1051
- log.push(v);
1052
- });
1053
- log.length = 0;
1054
- s.value = 1;
1055
- expect(log).toEqual([1]);
1056
- dispose();
1057
- log.length = 0;
1058
- s.value = 2;
1059
- expect(log).toEqual([]);
1060
- });
1061
-
1062
- it("computed that throws then recovers retains correct dep tracking", () => {
1063
- const a = signal(0);
1064
- const b = signal(10);
1065
- let shouldThrow = false;
1066
- const c = computed(() => {
1067
- const av = a.value;
1068
- if (shouldThrow) {
1069
- throw new Error("mid-track");
1070
- }
1071
- return av + b.value;
1072
- });
1073
- expect(c.value).toBe(10);
1074
- shouldThrow = true;
1075
- a.value = 1;
1076
- expect(() => c.value).toThrow("mid-track");
1077
- // Now recover: both a and b should still be tracked
1078
- shouldThrow = false;
1079
- a.value = 2;
1080
- expect(c.value).toBe(12);
1081
- b.value = 20;
1082
- expect(c.value).toBe(22);
1083
- });
1084
- });