@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,771 @@
1
+ // @vitest-environment happy-dom
2
+ // RED-TEAM round-3 final convergence sweep
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import {
5
+ signal,
6
+ effect,
7
+ batch,
8
+ computed,
9
+ untracked,
10
+ subscribe,
11
+ setEffectErrorHandler,
12
+ createStore,
13
+ reconcile,
14
+ patch,
15
+ reconcileChildren,
16
+ } from "./index.js";
17
+ import type { EffectErrorHandler, ReadonlySignal } from "./index.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // 1. Verify round-1/2 fixes are sound
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe("round-1/2 fix verification", () => {
24
+ let prevHandler: EffectErrorHandler;
25
+ beforeEach(() => {
26
+ prevHandler = setEffectErrorHandler(() => {
27
+ /* swallow */
28
+ });
29
+ });
30
+ afterEach(() => {
31
+ setEffectErrorHandler(prevHandler);
32
+ });
33
+
34
+ it("drainPending try/finally: flushing resets even with cascading throws", () => {
35
+ const s = signal(0);
36
+ // Create 3 effects that all throw
37
+ effect(() => {
38
+ if (s.value > 0) {
39
+ throw new Error("e1");
40
+ }
41
+ return undefined;
42
+ });
43
+ effect(() => {
44
+ if (s.value > 0) {
45
+ throw new Error("e2");
46
+ }
47
+ return undefined;
48
+ });
49
+ effect(() => {
50
+ if (s.value > 0) {
51
+ throw new Error("e3");
52
+ }
53
+ return undefined;
54
+ });
55
+ s.value = 1;
56
+ // System must still work
57
+ const spy = vi.fn();
58
+ effect(() => {
59
+ spy(s.value);
60
+ return undefined;
61
+ });
62
+ spy.mockClear();
63
+ s.value = 2;
64
+ expect(spy).toHaveBeenCalledWith(2);
65
+ });
66
+
67
+ it("dispose re-entrancy guard: rapid dispose-dispose-dispose", () => {
68
+ const s = signal(0);
69
+ let cleanups = 0;
70
+ const dispose = effect(() => {
71
+ void s.value;
72
+ return () => {
73
+ cleanups++;
74
+ };
75
+ });
76
+ dispose();
77
+ dispose();
78
+ dispose();
79
+ expect(cleanups).toBe(1);
80
+ });
81
+
82
+ it("cleanup cleared before invocation prevents double-call on re-trigger", () => {
83
+ const s = signal(0);
84
+ let cleanups = 0;
85
+ effect(() => {
86
+ void s.value;
87
+ return () => {
88
+ cleanups++;
89
+ };
90
+ });
91
+ s.value = 1;
92
+ s.value = 2;
93
+ s.value = 3;
94
+ expect(cleanups).toBe(3); // exactly one per re-run
95
+ });
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // 2. Pathological diamond/fan-out graphs
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe("pathological graphs", () => {
103
+ it("deep diamond (10 levels) fires leaf effect exactly once per change", () => {
104
+ const root = signal(0);
105
+ // Build diamond: each level splits into 2 computeds that merge at next level
106
+ let nodes: ReadonlySignal<number>[] = [computed(() => root.value)];
107
+ for (let level = 0; level < 10; level++) {
108
+ const next: ReadonlySignal<number>[] = [];
109
+ for (const n of nodes) {
110
+ next.push(computed(() => n.value + 1));
111
+ next.push(computed(() => n.value + 2));
112
+ }
113
+ // Merge pairs
114
+ const merged: ReadonlySignal<number>[] = [];
115
+ for (let i = 0; i < next.length; i += 2) {
116
+ merged.push(computed(() => next[i]!.value + next[i + 1]!.value));
117
+ }
118
+ nodes = merged;
119
+ }
120
+ const leaf = nodes[0]!;
121
+ const spy = vi.fn();
122
+ effect(() => {
123
+ spy(leaf.value);
124
+ return undefined;
125
+ });
126
+ spy.mockClear();
127
+ root.value = 1;
128
+ expect(spy).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it("fan-out: 100 effects on same signal, all fire exactly once", () => {
132
+ const s = signal(0);
133
+ const spies = Array.from({ length: 100 }, () => vi.fn());
134
+ for (const spy of spies) {
135
+ effect(() => {
136
+ spy(s.value);
137
+ return undefined;
138
+ });
139
+ }
140
+ for (const spy of spies) {
141
+ spy.mockClear();
142
+ }
143
+ s.value = 1;
144
+ for (const spy of spies) {
145
+ expect(spy).toHaveBeenCalledTimes(1);
146
+ expect(spy).toHaveBeenCalledWith(1);
147
+ }
148
+ });
149
+
150
+ it("fan-in: 50 signals → 1 computed → 1 effect, batch update all", () => {
151
+ const signals = Array.from({ length: 50 }, (_, i) => signal(i));
152
+ const sum = computed(() => signals.reduce((acc, s) => acc + s.value, 0));
153
+ const spy = vi.fn();
154
+ effect(() => {
155
+ spy(sum.value);
156
+ return undefined;
157
+ });
158
+ spy.mockClear();
159
+ batch(() => {
160
+ for (const s of signals) {
161
+ s.value = s.peek() + 1;
162
+ }
163
+ });
164
+ expect(spy).toHaveBeenCalledTimes(1);
165
+ expect(spy).toHaveBeenCalledWith(1275); // original sum(0..49)=1225, each +1 → sum(1..50)=1275
166
+ });
167
+
168
+ it("computed cycle detection throws", () => {
169
+ // eslint-disable-next-line prefer-const
170
+ let b: ReadonlySignal<number>;
171
+ const a = computed(() => b.value + 1);
172
+ b = computed(() => a.value + 1);
173
+ expect(() => a.value).toThrow("Cycle detected");
174
+ });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // 3. Exception in every callback type
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe("exception in every callback type", () => {
182
+ let prevHandler: EffectErrorHandler;
183
+ const errors: unknown[] = [];
184
+ beforeEach(() => {
185
+ errors.length = 0;
186
+ prevHandler = setEffectErrorHandler((e) => {
187
+ errors.push(e);
188
+ });
189
+ });
190
+ afterEach(() => {
191
+ setEffectErrorHandler(prevHandler);
192
+ });
193
+
194
+ it("exception in effect cleanup does not prevent re-execution", () => {
195
+ const s = signal(0);
196
+ const values: number[] = [];
197
+ effect(() => {
198
+ values.push(s.value);
199
+ return () => {
200
+ throw new Error("cleanup boom");
201
+ };
202
+ });
203
+ s.value = 1;
204
+ s.value = 2;
205
+ // Effect should still track and re-run despite cleanup throwing
206
+ expect(values).toContain(0);
207
+ expect(values).toContain(1);
208
+ expect(values).toContain(2);
209
+ });
210
+
211
+ it("exception in computed fn is cached and re-thrown on read", () => {
212
+ const s = signal(0);
213
+ const c = computed(() => {
214
+ if (s.value === 1) {
215
+ throw new Error("computed boom");
216
+ }
217
+ return s.value * 2;
218
+ });
219
+ expect(c.value).toBe(0);
220
+ s.value = 1;
221
+ expect(() => c.value).toThrow("computed boom");
222
+ // Recovery
223
+ s.value = 2;
224
+ expect(c.value).toBe(4);
225
+ });
226
+
227
+ it("exception in subscribe callback is caught by error handler", () => {
228
+ const s = signal(0);
229
+ subscribe(s, (v) => {
230
+ if (v === 1) {
231
+ throw new Error("subscribe boom");
232
+ }
233
+ });
234
+ s.value = 1;
235
+ expect(errors.some((e) => (e as Error).message === "subscribe boom")).toBe(true);
236
+ });
237
+
238
+ it("exception in batch fn propagates but effects still flush", () => {
239
+ const s = signal(0);
240
+ const spy = vi.fn();
241
+ effect(() => {
242
+ spy(s.value);
243
+ return undefined;
244
+ });
245
+ spy.mockClear();
246
+ expect(() => {
247
+ batch(() => {
248
+ s.value = 99;
249
+ throw new Error("batch boom");
250
+ });
251
+ }).toThrow("batch boom");
252
+ // Effect should have flushed due to try/finally in batch
253
+ expect(spy).toHaveBeenCalledWith(99);
254
+ });
255
+
256
+ it("exception in on() body is caught by error handler", () => {
257
+ const s = signal(0);
258
+ const fn = vi.fn();
259
+ effect(() => {
260
+ const v = s.value;
261
+ if (v === 1) {
262
+ throw new Error("on body boom");
263
+ }
264
+ fn(v);
265
+ return undefined;
266
+ });
267
+ fn.mockClear();
268
+ s.value = 1;
269
+ expect(errors.some((e) => (e as Error).message === "on body boom")).toBe(true);
270
+ // Recovery
271
+ s.value = 2;
272
+ expect(fn).toHaveBeenCalledWith(2);
273
+ });
274
+ });
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // 4. Dispose ordering
278
+ // ---------------------------------------------------------------------------
279
+
280
+ describe("dispose ordering", () => {
281
+ it("disposing effects in reverse order of creation", () => {
282
+ const s = signal(0);
283
+ const order: string[] = [];
284
+ const disposers = Array.from({ length: 5 }, (_, i) =>
285
+ effect(() => {
286
+ order.push(`e${i}:${s.value}`);
287
+ return () => {
288
+ order.push(`c${i}`);
289
+ };
290
+ }),
291
+ );
292
+ order.length = 0;
293
+ // Dispose in reverse
294
+ for (let i = 4; i >= 0; i--) {
295
+ disposers[i]!();
296
+ }
297
+ expect(order).toEqual(["c4", "c3", "c2", "c1", "c0"]);
298
+ // No effects should fire
299
+ order.length = 0;
300
+ s.value = 1;
301
+ expect(order).toEqual([]);
302
+ });
303
+
304
+ it("disposing during flush does not corrupt iteration", () => {
305
+ const s = signal(0);
306
+ const spy = vi.fn();
307
+ const disposers: (() => void)[] = [];
308
+ // Effect 0 disposes effects 1 and 2 when triggered
309
+ disposers.push(
310
+ effect(() => {
311
+ if (s.value === 1) {
312
+ disposers[1]?.();
313
+ disposers[2]?.();
314
+ }
315
+ return undefined;
316
+ }),
317
+ );
318
+ disposers.push(
319
+ effect(() => {
320
+ spy("e1:" + s.value);
321
+ return undefined;
322
+ }),
323
+ );
324
+ disposers.push(
325
+ effect(() => {
326
+ spy("e2:" + s.value);
327
+ return undefined;
328
+ }),
329
+ );
330
+ disposers.push(
331
+ effect(() => {
332
+ spy("e3:" + s.value);
333
+ return undefined;
334
+ }),
335
+ );
336
+ spy.mockClear();
337
+ s.value = 1;
338
+ // e3 should still fire; e1 and e2 may or may not depending on order
339
+ expect(spy).toHaveBeenCalledWith("e3:1");
340
+ });
341
+ });
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // 5. Memory leak check after mass dispose
345
+ // ---------------------------------------------------------------------------
346
+
347
+ describe("memory leak after mass dispose", () => {
348
+ it("disposed effects are fully unlinked from signal subscriber sets", () => {
349
+ const s = signal(0);
350
+ const disposers: (() => void)[] = [];
351
+ for (let i = 0; i < 1000; i++) {
352
+ disposers.push(
353
+ effect(() => {
354
+ void s.value;
355
+ return undefined;
356
+ }),
357
+ );
358
+ }
359
+ // Dispose all
360
+ for (const d of disposers) {
361
+ d();
362
+ }
363
+ // Verify: setting signal should not trigger anything
364
+ const spy = vi.fn();
365
+ const d2 = effect(() => {
366
+ spy(s.value);
367
+ return undefined;
368
+ });
369
+ spy.mockClear();
370
+ s.value = 1;
371
+ expect(spy).toHaveBeenCalledTimes(1);
372
+ d2();
373
+ });
374
+
375
+ it("disposed computed does not retain references to source signals", () => {
376
+ const s = signal(0);
377
+ const computeds: ReturnType<typeof computed>[] = [];
378
+ const disposers: (() => void)[] = [];
379
+ for (let i = 0; i < 100; i++) {
380
+ const c = computed(() => s.value + i);
381
+ computeds.push(c);
382
+ disposers.push(
383
+ effect(() => {
384
+ void c.value;
385
+ return undefined;
386
+ }),
387
+ );
388
+ }
389
+ // Dispose all effects (computeds become lazy/unsubscribed)
390
+ for (const d of disposers) {
391
+ d();
392
+ }
393
+ // Signal update should not trigger any computation
394
+ const spy = vi.fn();
395
+ const d2 = effect(() => {
396
+ spy(s.value);
397
+ return undefined;
398
+ });
399
+ spy.mockClear();
400
+ s.value = 99;
401
+ expect(spy).toHaveBeenCalledTimes(1);
402
+ expect(spy).toHaveBeenCalledWith(99);
403
+ d2();
404
+ });
405
+ });
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // 6. Batch + computed + effect interleaving
409
+ // ---------------------------------------------------------------------------
410
+
411
+ describe("batch + computed + effect interleaving", () => {
412
+ it("batch with computed read mid-batch sees consistent state after flush", () => {
413
+ const a = signal(1);
414
+ const b = signal(2);
415
+ const sum = computed(() => a.value + b.value);
416
+ const values: number[] = [];
417
+ effect(() => {
418
+ values.push(sum.value);
419
+ return undefined;
420
+ });
421
+ values.length = 0;
422
+ batch(() => {
423
+ a.value = 10;
424
+ b.value = 20;
425
+ });
426
+ // After batch: effect fires with final value
427
+ expect(values).toContain(30); // 10 + 20
428
+ });
429
+
430
+ it("nested batch: inner batch does not flush", () => {
431
+ const s = signal(0);
432
+ const spy = vi.fn();
433
+ effect(() => {
434
+ spy(s.value);
435
+ return undefined;
436
+ });
437
+ spy.mockClear();
438
+ batch(() => {
439
+ batch(() => {
440
+ s.value = 1;
441
+ });
442
+ // Inner batch ended but outer still active - no flush yet
443
+ expect(spy).not.toHaveBeenCalled();
444
+ s.value = 2;
445
+ });
446
+ // Only final value should be seen
447
+ expect(spy).toHaveBeenCalledWith(2);
448
+ expect(spy).toHaveBeenCalledTimes(1);
449
+ });
450
+
451
+ it("effect creating new effect during batch flush", () => {
452
+ const s = signal(0);
453
+ const inner = vi.fn();
454
+ effect(() => {
455
+ if (s.value === 1) {
456
+ // Create new effect during flush
457
+ effect(() => {
458
+ inner(s.value);
459
+ return undefined;
460
+ });
461
+ }
462
+ return undefined;
463
+ });
464
+ batch(() => {
465
+ s.value = 1;
466
+ });
467
+ expect(inner).toHaveBeenCalledWith(1);
468
+ });
469
+
470
+ it("computed invalidation during batch does not cause double-fire", () => {
471
+ const a = signal(0);
472
+ const b = signal(0);
473
+ const c = computed(() => a.value + b.value);
474
+ const spy = vi.fn();
475
+ effect(() => {
476
+ spy(c.value);
477
+ return undefined;
478
+ });
479
+ spy.mockClear();
480
+ batch(() => {
481
+ a.value = 1;
482
+ b.value = 1;
483
+ });
484
+ expect(spy).toHaveBeenCalledTimes(1);
485
+ expect(spy).toHaveBeenCalledWith(2);
486
+ });
487
+ });
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // 7. Reconcile fuzz
491
+ // ---------------------------------------------------------------------------
492
+
493
+ describe("reconcile fuzz", () => {
494
+ function makeSpec() {
495
+ return {
496
+ key: (i: { id: string }) => i.id,
497
+ mount: (i: { id: string; v: number }) => {
498
+ const el = document.createElement("div");
499
+ el.textContent = String(i.v);
500
+ return el;
501
+ },
502
+ update: (el: HTMLElement, i: { id: string; v: number }) => {
503
+ el.textContent = String(i.v);
504
+ },
505
+ onRemove: vi.fn(),
506
+ };
507
+ }
508
+
509
+ it("random insert/remove/reorder 50 iterations", () => {
510
+ const parent = document.createElement("div");
511
+ const spec = makeSpec();
512
+ const items: { id: string; v: number }[] = [];
513
+ let nextId = 0;
514
+ let seed = 42;
515
+ const rng = () => {
516
+ seed = (seed * 1664525 + 1013904223) >>> 0;
517
+ return seed / 0x100000000;
518
+ };
519
+
520
+ for (let iter = 0; iter < 50; iter++) {
521
+ const action = rng();
522
+ if (action < 0.3 && items.length > 0) {
523
+ // Remove random
524
+ const idx = Math.floor(rng() * items.length);
525
+ items.splice(idx, 1);
526
+ } else if (action < 0.6) {
527
+ // Insert random
528
+ const idx = Math.floor(rng() * (items.length + 1));
529
+ items.splice(idx, 0, { id: `id${nextId++}`, v: iter });
530
+ } else if (items.length > 1) {
531
+ // Swap two random
532
+ const i = Math.floor(rng() * items.length);
533
+ let j = Math.floor(rng() * items.length);
534
+ if (j === i) {
535
+ j = (j + 1) % items.length;
536
+ }
537
+ [items[i], items[j]] = [items[j]!, items[i]!];
538
+ }
539
+ reconcile(parent, items, spec);
540
+ expect(parent.children.length).toBe(items.length);
541
+ // Verify order
542
+ for (let k = 0; k < items.length; k++) {
543
+ expect(parent.children[k]!.getAttribute("data-reconcile-key")).toBe(items[k]!.id);
544
+ }
545
+ }
546
+ });
547
+
548
+ it("reconcile with empty list clears all", () => {
549
+ const parent = document.createElement("div");
550
+ const spec = makeSpec();
551
+ const items = Array.from({ length: 20 }, (_, i) => ({ id: `k${i}`, v: i }));
552
+ reconcile(parent, items, spec);
553
+ expect(parent.children.length).toBe(20);
554
+ reconcile(parent, [], spec);
555
+ expect(parent.children.length).toBe(0);
556
+ expect(spec.onRemove).toHaveBeenCalledTimes(20);
557
+ });
558
+
559
+ it("reconcile with duplicate keys (pathological input)", () => {
560
+ const parent = document.createElement("div");
561
+ const spec = makeSpec();
562
+ // Duplicate keys - library should handle gracefully (last wins or first wins)
563
+ const items = [
564
+ { id: "dup", v: 1 },
565
+ { id: "dup", v: 2 },
566
+ { id: "unique", v: 3 },
567
+ ];
568
+ // Should not throw
569
+ expect(() => reconcile(parent, items, spec)).not.toThrow();
570
+ });
571
+ });
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // 8. Fake timers: effect scheduling is synchronous (no microtask leaks)
575
+ // ---------------------------------------------------------------------------
576
+
577
+ describe("synchronous semantics (fake timers)", () => {
578
+ beforeEach(() => {
579
+ vi.useFakeTimers();
580
+ });
581
+ afterEach(() => {
582
+ vi.useRealTimers();
583
+ });
584
+
585
+ it("signal write + effect is fully synchronous, no pending microtasks", () => {
586
+ const s = signal(0);
587
+ const spy = vi.fn();
588
+ effect(() => {
589
+ spy(s.value);
590
+ return undefined;
591
+ });
592
+ spy.mockClear();
593
+ s.value = 1;
594
+ // Should already have fired - no need to advance timers
595
+ expect(spy).toHaveBeenCalledWith(1);
596
+ });
597
+
598
+ it("batch flush is synchronous", () => {
599
+ const s = signal(0);
600
+ const spy = vi.fn();
601
+ effect(() => {
602
+ spy(s.value);
603
+ return undefined;
604
+ });
605
+ spy.mockClear();
606
+ batch(() => {
607
+ s.value = 42;
608
+ });
609
+ expect(spy).toHaveBeenCalledWith(42);
610
+ });
611
+
612
+ it("computed is evaluated lazily and synchronously", () => {
613
+ const s = signal(1);
614
+ const c = computed(() => s.value * 2);
615
+ s.value = 5;
616
+ expect(c.value).toBe(10); // No timer needed
617
+ });
618
+ });
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // 9. untracked edge cases
622
+ // ---------------------------------------------------------------------------
623
+
624
+ describe("untracked edge cases", () => {
625
+ it("untracked inside effect prevents tracking", () => {
626
+ const a = signal(0);
627
+ const b = signal(0);
628
+ const spy = vi.fn();
629
+ effect(() => {
630
+ const av = a.value; // tracked
631
+ const bv = untracked(() => b.value); // not tracked
632
+ spy(av, bv);
633
+ return undefined;
634
+ });
635
+ spy.mockClear();
636
+ b.value = 99;
637
+ expect(spy).not.toHaveBeenCalled();
638
+ a.value = 1;
639
+ expect(spy).toHaveBeenCalledWith(1, 99);
640
+ });
641
+
642
+ it("untracked with throwing fn restores tracking context", () => {
643
+ const s = signal(0);
644
+ const spy = vi.fn();
645
+ effect(() => {
646
+ try {
647
+ untracked(() => {
648
+ throw new Error("boom");
649
+ });
650
+ } catch {
651
+ /* swallow */
652
+ }
653
+ spy(s.value); // should still be tracked
654
+ return undefined;
655
+ });
656
+ spy.mockClear();
657
+ s.value = 1;
658
+ expect(spy).toHaveBeenCalledWith(1);
659
+ });
660
+ });
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // 10. Store: batch + effect + computed interleaving
664
+ // ---------------------------------------------------------------------------
665
+
666
+ describe("store: batch + effect + computed interleaving", () => {
667
+ it("store computed updates atomically in batch", () => {
668
+ const store = createStore<{ a: number; b: number; sum: number }>();
669
+ store.set("a", 1);
670
+ store.set("b", 2);
671
+ store.computed("sum", () => store.get("a") + store.get("b"));
672
+ expect(store.get("sum")).toBe(3);
673
+ const spy = vi.fn();
674
+ store.subscribe("sum", spy);
675
+ store.batch(() => {
676
+ store.set("a", 10);
677
+ store.set("b", 20);
678
+ });
679
+ // sum should update to 30
680
+ expect(store.get("sum")).toBe(30);
681
+ });
682
+
683
+ it("store effect throwing does not break subsequent effects", () => {
684
+ const store = createStore<{ x: number }>();
685
+ store.set("x", 0);
686
+ const spy = vi.fn();
687
+ store.effect(() => {
688
+ if (store.get("x") === 1) {
689
+ throw new Error("store effect boom");
690
+ }
691
+ return undefined;
692
+ });
693
+ store.effect(() => {
694
+ spy(store.get("x"));
695
+ return undefined;
696
+ });
697
+ spy.mockClear();
698
+ // The throwing effect will console.error but shouldn't prevent spy effect
699
+ const origError = console.error;
700
+ console.error = vi.fn();
701
+ store.set("x", 1);
702
+ console.error = origError;
703
+ expect(spy).toHaveBeenCalledWith(1);
704
+ });
705
+ });
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // 11. Signal equality: custom equals with side effects (adversarial)
709
+ // ---------------------------------------------------------------------------
710
+
711
+ describe("adversarial equals", () => {
712
+ it("equals that reads another signal does not corrupt tracking", () => {
713
+ const a = signal(0);
714
+ const b = signal(0, {
715
+ equals: (prev, next) => {
716
+ // Adversarial: read another signal inside equals
717
+ void a.peek(); // peek is safe
718
+ return prev === next;
719
+ },
720
+ });
721
+ const spy = vi.fn();
722
+ effect(() => {
723
+ spy(b.value);
724
+ return undefined;
725
+ });
726
+ spy.mockClear();
727
+ b.value = 1;
728
+ expect(spy).toHaveBeenCalledWith(1);
729
+ });
730
+
731
+ it("equals returning false always causes notification", () => {
732
+ const s = signal(0, { equals: false });
733
+ const spy = vi.fn();
734
+ effect(() => {
735
+ spy(s.value);
736
+ return undefined;
737
+ });
738
+ spy.mockClear();
739
+ s.value = 0; // same value but equals=false means always notify
740
+ expect(spy).toHaveBeenCalledWith(0);
741
+ });
742
+ });
743
+
744
+ // ---------------------------------------------------------------------------
745
+ // 12. reconcileChildren / patch edge cases
746
+ // ---------------------------------------------------------------------------
747
+
748
+ describe("reconcileChildren / patch edge cases", () => {
749
+ it("patch with null/undefined children is no-op", () => {
750
+ const parent = document.createElement("div");
751
+ parent.textContent = "hello";
752
+ patch(parent, null, undefined);
753
+ expect(parent.childNodes.length).toBe(0);
754
+ });
755
+
756
+ it("patch with DocumentFragment spreads children", () => {
757
+ const parent = document.createElement("div");
758
+ const frag = document.createDocumentFragment();
759
+ frag.appendChild(document.createElement("span"));
760
+ frag.appendChild(document.createTextNode("text"));
761
+ patch(parent, frag);
762
+ expect(parent.childNodes.length).toBe(2);
763
+ });
764
+
765
+ it("reconcileChildren handles text node update", () => {
766
+ const parent = document.createElement("div");
767
+ parent.appendChild(document.createTextNode("old"));
768
+ reconcileChildren(parent, [document.createTextNode("new")]);
769
+ expect(parent.textContent).toBe("new");
770
+ });
771
+ });