@sigil-dev/runtime 0.7.7 → 0.8.0

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.
Files changed (2) hide show
  1. package/index.test.ts +1616 -1612
  2. package/package.json +1 -1
package/index.test.ts CHANGED
@@ -1,1612 +1,1616 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- batch,
4
- claim,
5
- claimComment,
6
- claimText,
7
- createEffect,
8
- createEffectPre,
9
- createInspectEffect,
10
- createMemo,
11
- createProfilerEffect,
12
- createRawSignal,
13
- createSignal,
14
- easeInCubic,
15
- easeInOutCubic,
16
- easeInOutQuad,
17
- easeInOutQuart,
18
- easeInOutQuint,
19
- easeInOutSine,
20
- easeInQuad,
21
- easeInQuart,
22
- easeInQuint,
23
- easeInSine,
24
- easeOutCubic,
25
- easeOutQuad,
26
- easeOutQuart,
27
- easeOutQuint,
28
- easeOutSine,
29
- hydrateKeyedList,
30
- linear,
31
- ReactiveMap,
32
- ReactiveSet,
33
- reconcile,
34
- snapshot,
35
- tick,
36
- tracking,
37
- withEffectScope,
38
- } from "./index.ts";
39
-
40
- // Minimal DOM mock for reconcile tests
41
- class MockNode {
42
- parentNode: MockNode | null = null;
43
- children: MockNode[] = [];
44
- name: string;
45
- constructor(name: string) {
46
- this.name = name;
47
- }
48
- before(node: MockNode) {
49
- // Remove from old parent
50
- if (node.parentNode) {
51
- const idx = node.parentNode.children.indexOf(node);
52
- if (idx >= 0) node.parentNode.children.splice(idx, 1);
53
- }
54
- // Insert before this node in parent
55
- if (this.parentNode) {
56
- const idx = this.parentNode.children.indexOf(this);
57
- this.parentNode.children.splice(idx, 0, node);
58
- node.parentNode = this.parentNode;
59
- }
60
- }
61
- removeChild(node: MockNode) {
62
- const idx = this.children.indexOf(node);
63
- if (idx >= 0) this.children.splice(idx, 1);
64
- node.parentNode = null;
65
- }
66
- toString() {
67
- return this.name;
68
- }
69
- }
70
- class MockComment extends MockNode {
71
- constructor() {
72
- super("");
73
- }
74
- }
75
- class MockElement extends MockNode {}
76
-
77
- function nodeNames(parent: MockNode): string[] {
78
- return parent.children.filter((c) => c.name !== "").map((c) => c.name);
79
- }
80
-
81
- describe("Runtime", () => {
82
- describe("Basic reactivity", () => {
83
- describe("createSignal", () => {
84
- test("returns initial value", () => {
85
- const count = createSignal(0);
86
- expect(count()).toBe(0);
87
- });
88
-
89
- test("updates on set", () => {
90
- const count = createSignal(0);
91
- count.set(5);
92
- expect(count()).toBe(5);
93
- });
94
- });
95
-
96
- describe("createEffect", () => {
97
- test("runs immediately", () => {
98
- const count = createSignal(0);
99
- let seen = -1;
100
- createEffect(() => {
101
- seen = count();
102
- });
103
- expect(seen).toBe(0);
104
- });
105
-
106
- test("reruns on signal change", () => {
107
- const count = createSignal(0);
108
- let seen = -1;
109
- createEffect(() => {
110
- seen = count();
111
- });
112
- batch(() => {
113
- count.set(5);
114
- });
115
- expect(seen).toBe(5);
116
- });
117
- });
118
-
119
- describe("createMemo", () => {
120
- test("derives value", () => {
121
- const count = createSignal(2);
122
- const doubled = createMemo(() => count() * 2);
123
- expect(doubled()).toBe(4);
124
- });
125
-
126
- test("updates when dependency changes", () => {
127
- const count = createSignal(2);
128
- const doubled = createMemo(() => count() * 2);
129
- batch(() => {
130
- count.set(5);
131
- });
132
- expect(doubled()).toBe(10);
133
- });
134
- });
135
- });
136
-
137
- describe("reconcile", () => {
138
- function setup() {
139
- const parent = new MockElement("parent");
140
- const anchor = new MockComment();
141
- parent.children.push(anchor);
142
- anchor.parentNode = parent;
143
- const keyMap = new Map<unknown, MockNode>();
144
- return { parent, anchor, keyMap };
145
- }
146
-
147
- /** Cast wrapper — MockNode/MockElement satisfy the DOM APIs reconcile actually uses */
148
- function mockReconcile<T>(
149
- anchor: MockComment,
150
- keyMap: Map<unknown, MockNode>,
151
- newItems: T[],
152
- getKey: (item: T) => unknown,
153
- createNode: (item: T) => MockNode,
154
- ) {
155
- reconcile(
156
- anchor as unknown as Comment,
157
- keyMap as unknown as Map<unknown, Node>,
158
- newItems,
159
- getKey,
160
- createNode as unknown as (item: T) => Node,
161
- );
162
- }
163
-
164
- test("inserts initial items", () => {
165
- const { parent, anchor, keyMap } = setup();
166
- const items = [
167
- { id: 1, text: "a" },
168
- { id: 2, text: "b" },
169
- ];
170
-
171
- mockReconcile(
172
- anchor,
173
- keyMap,
174
- items,
175
- (i) => i.id,
176
- (i) => new MockElement(i.text),
177
- );
178
-
179
- expect(nodeNames(parent)).toEqual(["a", "b"]);
180
- expect(keyMap.size).toBe(2);
181
- });
182
-
183
- test("reuses nodes with same key", () => {
184
- const { parent, anchor, keyMap } = setup();
185
- const items = [
186
- { id: 1, text: "a" },
187
- { id: 2, text: "b" },
188
- ];
189
-
190
- mockReconcile(
191
- anchor,
192
- keyMap,
193
- items,
194
- (i) => i.id,
195
- (i) => new MockElement(i.text),
196
- );
197
- const nodeA = keyMap.get(1);
198
-
199
- mockReconcile(
200
- anchor,
201
- keyMap,
202
- items,
203
- (i) => i.id,
204
- (i) => new MockElement(i.text),
205
- );
206
-
207
- expect(keyMap.get(1)).toBe(nodeA);
208
- expect(nodeNames(parent)).toEqual(["a", "b"]);
209
- });
210
-
211
- test("removes deleted items", () => {
212
- const { parent, anchor, keyMap } = setup();
213
- mockReconcile(
214
- anchor,
215
- keyMap,
216
- [{ id: 1 }, { id: 2 }, { id: 3 }],
217
- (i) => i.id,
218
- (i) => new MockElement(`n${i.id}`),
219
- );
220
- expect(nodeNames(parent)).toEqual(["n1", "n2", "n3"]);
221
-
222
- mockReconcile(
223
- anchor,
224
- keyMap,
225
- [{ id: 1 }, { id: 3 }],
226
- (i) => i.id,
227
- (i) => new MockElement(`n${i.id}`),
228
- );
229
-
230
- expect(nodeNames(parent)).toEqual(["n1", "n3"]);
231
- expect(keyMap.has(2)).toBe(false);
232
- });
233
-
234
- test("moves nodes to new order", () => {
235
- const { parent, anchor, keyMap } = setup();
236
- mockReconcile(
237
- anchor,
238
- keyMap,
239
- [{ id: 1 }, { id: 2 }, { id: 3 }],
240
- (i) => i.id,
241
- (i) => new MockElement(`n${i.id}`),
242
- );
243
- const node1 = keyMap.get(1);
244
- const node2 = keyMap.get(2);
245
- const node3 = keyMap.get(3);
246
-
247
- mockReconcile(
248
- anchor,
249
- keyMap,
250
- [{ id: 3 }, { id: 2 }, { id: 1 }],
251
- (i) => i.id,
252
- (i) => new MockElement(`n${i.id}`),
253
- );
254
-
255
- expect(keyMap.get(1)).toBe(node1);
256
- expect(keyMap.get(2)).toBe(node2);
257
- expect(keyMap.get(3)).toBe(node3);
258
- expect(nodeNames(parent)).toEqual(["n3", "n2", "n1"]);
259
- });
260
-
261
- test("handles mixed add/remove/reorder", () => {
262
- const { parent, anchor, keyMap } = setup();
263
- mockReconcile(
264
- anchor,
265
- keyMap,
266
- [{ id: 1 }, { id: 2 }, { id: 3 }],
267
- (i) => i.id,
268
- (i) => new MockElement(`n${i.id}`),
269
- );
270
- const node1 = keyMap.get(1);
271
- const node3 = keyMap.get(3);
272
-
273
- mockReconcile(
274
- anchor,
275
- keyMap,
276
- [{ id: 4 }, { id: 3 }, { id: 1 }],
277
- (i) => i.id,
278
- (i) => new MockElement(`n${i.id}`),
279
- );
280
-
281
- expect(keyMap.has(2)).toBe(false);
282
- expect(keyMap.get(1)).toBe(node1);
283
- expect(keyMap.get(3)).toBe(node3);
284
- expect(keyMap.has(4)).toBe(true);
285
- expect(nodeNames(parent)).toEqual(["n4", "n3", "n1"]);
286
- });
287
-
288
- test("empty list removes all", () => {
289
- const { parent, anchor, keyMap } = setup();
290
- mockReconcile(
291
- anchor,
292
- keyMap,
293
- [{ id: 1 }, { id: 2 }],
294
- (i) => i.id,
295
- (i) => new MockElement(`n${i.id}`),
296
- );
297
- expect(nodeNames(parent)).toEqual(["n1", "n2"]);
298
-
299
- mockReconcile(
300
- anchor,
301
- keyMap,
302
- [] as { id: number }[],
303
- (i) => i.id,
304
- (i) => new MockElement(`n${i.id}`),
305
- );
306
-
307
- expect(nodeNames(parent)).toEqual([]);
308
- expect(keyMap.size).toBe(0);
309
- });
310
-
311
- test("from empty to populated", () => {
312
- const { parent, anchor, keyMap } = setup();
313
- expect(nodeNames(parent)).toEqual([]);
314
-
315
- mockReconcile(
316
- anchor,
317
- keyMap,
318
- [{ id: 1 }, { id: 2 }],
319
- (i) => i.id,
320
- (i) => new MockElement(`n${i.id}`),
321
- );
322
-
323
- expect(nodeNames(parent)).toEqual(["n1", "n2"]);
324
- });
325
-
326
- test("preserves anchor position", () => {
327
- const { parent, anchor, keyMap } = setup();
328
- const header = new MockElement("header");
329
- anchor.before(header);
330
-
331
- mockReconcile(
332
- anchor,
333
- keyMap,
334
- [{ id: 1 }],
335
- (i) => i.id,
336
- () => new MockElement("item"),
337
- );
338
-
339
- expect(nodeNames(parent)).toEqual(["header", "item"]);
340
- });
341
- });
342
-
343
- test("effect cleanup runs before rerun", () => {
344
- const count = createSignal(0);
345
- const log: string[] = [];
346
- createEffect(() => {
347
- count(); // subscribe
348
- return () => log.push("cleanup");
349
- });
350
- count.set(1);
351
- count.set(2);
352
- batch(() => {});
353
- expect(log).toEqual(["cleanup"]);
354
- });
355
-
356
- test("disposer stops effect", () => {
357
- const count = createSignal(0);
358
- let runs = 0;
359
- const stop = createEffect(() => {
360
- count();
361
- runs++;
362
- });
363
- batch(() => {
364
- count.set(1);
365
- });
366
- stop();
367
- batch(() => {
368
- count.set(2);
369
- });
370
- expect(runs).toBe(2); // initial + one update, not three
371
- });
372
-
373
- test("cleanup runs on dispose", () => {
374
- let cleaned = false;
375
- const stop = createEffect(() => () => {
376
- cleaned = true;
377
- });
378
- stop();
379
- expect(cleaned).toBe(true);
380
- });
381
- });
382
-
383
- describe("batch", () => {
384
- test("multiple signals in batch trigger effect once", () => {
385
- const count = createSignal(0);
386
- const name = createSignal("a");
387
- let runs = 0;
388
- createEffect(() => {
389
- count();
390
- name();
391
- runs++;
392
- });
393
- expect(runs).toBe(1); // initial run
394
-
395
- batch(() => {
396
- count.set(1);
397
- name.set("b");
398
- });
399
- expect(runs).toBe(2); // one run, not two
400
- });
401
-
402
- test("nested batches flush on outermost exit", () => {
403
- const count = createSignal(0);
404
- let runs = 0;
405
- createEffect(() => {
406
- count();
407
- runs++;
408
- });
409
-
410
- batch(() => {
411
- batch(() => {
412
- count.set(1);
413
- });
414
- count.set(2); // still inside outer batch
415
- });
416
- expect(runs).toBe(2); // one run after outer exits
417
- });
418
-
419
- test("outside batch auto-batches to microtask", () => {
420
- const count = createSignal(0);
421
- let runs = 0;
422
- createEffect(() => {
423
- count();
424
- runs++;
425
- });
426
-
427
- count.set(1);
428
- count.set(2);
429
- // Without batch, effects are auto-batched — no synchronous run
430
- expect(runs).toBe(1); // initial only
431
-
432
- batch(() => {});
433
- expect(runs).toBe(2); // initial + one batched update (last write wins)
434
- });
435
- });
436
-
437
- describe("createSignal with objects", () => {
438
- test("property read returns value", () => {
439
- const state = createSignal({ name: "Alice" });
440
- expect(state().name).toBe("Alice");
441
- });
442
-
443
- test("property write triggers effect", () => {
444
- const state = createSignal({ name: "Alice" });
445
- let seen = "";
446
- createEffect(() => {
447
- seen = state().name;
448
- });
449
- expect(seen).toBe("Alice");
450
- batch(() => {
451
- state().name = "Bob";
452
- });
453
- expect(seen).toBe("Bob");
454
- });
455
-
456
- test("unrelated property change does not trigger effect", () => {
457
- const state = createSignal({ name: "Alice", age: 30 });
458
- let runs = 0;
459
- createEffect(() => {
460
- state().name;
461
- runs++;
462
- });
463
- expect(runs).toBe(1);
464
- batch(() => {
465
- state().age = 31;
466
- });
467
- expect(runs).toBe(1);
468
- batch(() => {
469
- state().name = "Bob";
470
- });
471
- expect(runs).toBe(2);
472
- });
473
-
474
- test("deeply nested property access is reactive", () => {
475
- const state = createSignal({ user: { address: { city: "NYC" } } });
476
- let seen = "";
477
- createEffect(() => {
478
- seen = state().user.address.city;
479
- });
480
- expect(seen).toBe("NYC");
481
- batch(() => {
482
- state().user.address.city = "LA";
483
- });
484
- expect(seen).toBe("LA");
485
- });
486
-
487
- test("proxyCache deduplication — same proxy for repeated nested access", () => {
488
- const state = createSignal({ address: { city: "Agartha" } });
489
- const a1 = state().address;
490
- const a2 = state().address;
491
- expect(a1).toBe(a2);
492
-
493
- let seen = "";
494
- createEffect(() => {
495
- seen = state().address.city;
496
- });
497
- expect(seen).toBe("Agartha");
498
- batch(() => {
499
- state().address.city = "Hyperborea";
500
- });
501
- expect(seen).toBe("Hyperborea");
502
- });
503
-
504
- test(".set() replaces entire object", () => {
505
- const state = createSignal({ name: "Alice", age: 30 });
506
- let seen = "";
507
- createEffect(() => {
508
- seen = state().name;
509
- });
510
- expect(seen).toBe("Alice");
511
- batch(() => {
512
- state.set({ name: "Charlie", age: 25 });
513
- });
514
- expect(seen).toBe("Charlie");
515
- });
516
-
517
- test(".peek() reads without tracking", () => {
518
- const state = createSignal({ name: "Alice" });
519
- let runs = 0;
520
- createEffect(() => {
521
- state.peek();
522
- runs++;
523
- });
524
- expect(runs).toBe(1);
525
- batch(() => {
526
- state().name = "Bob";
527
- });
528
- expect(runs).toBe(1);
529
- });
530
-
531
- test("JSON.stringify works on proxy", () => {
532
- const state = createSignal({ name: "Alice", age: 30 });
533
- expect(JSON.stringify(state())).toBe('{"name":"Alice","age":30}');
534
- });
535
-
536
- test("Object.keys works on proxy", () => {
537
- const state = createSignal({ name: "Alice", age: 30 });
538
- expect(Object.keys(state())).toEqual(["name", "age"]);
539
- });
540
-
541
- test("spread works on proxy", () => {
542
- const state = createSignal({ name: "Alice" });
543
- const copy = { ...state() };
544
- expect(copy).toEqual({ name: "Alice" });
545
- });
546
- });
547
-
548
- describe("createSignal with arrays", () => {
549
- test("length is reactive", () => {
550
- const items = createSignal([1, 2, 3]);
551
- let len = 0;
552
- createEffect(() => {
553
- len = items().length;
554
- });
555
- expect(len).toBe(3);
556
- batch(() => {
557
- items().push(4);
558
- });
559
- expect(len).toBe(4);
560
- });
561
-
562
- test("index access is reactive", () => {
563
- const items = createSignal([1, 2, 3]);
564
- let first = 0;
565
- //@ts-expect-error the assertion for that is below
566
- createEffect(() => {
567
- first = items()[0];
568
- });
569
- expect(first).toBe(1);
570
- batch(() => {
571
- items()[0] = 99;
572
- });
573
- expect(first).toBe(99);
574
- });
575
-
576
- test("push triggers effects that read length", () => {
577
- const items = createSignal<number[]>([]);
578
- let seen: number[] = [];
579
- createEffect(() => {
580
- seen = [...items()];
581
- });
582
- expect(seen).toEqual([]);
583
- batch(() => {
584
- items().push(1);
585
- });
586
- expect(seen).toEqual([1]);
587
- batch(() => {
588
- items().push(2);
589
- });
590
- expect(seen).toEqual([1, 2]);
591
- });
592
-
593
- test("pop triggers length effect", () => {
594
- const items = createSignal([1, 2, 3]);
595
- let len = 0;
596
- createEffect(() => {
597
- len = items().length;
598
- });
599
- expect(len).toBe(3);
600
- batch(() => {
601
- items().pop();
602
- });
603
- expect(len).toBe(2);
604
- });
605
-
606
- test("splice triggers effects", () => {
607
- const items = createSignal([1, 2, 3, 4]);
608
- let seen: number[] = [];
609
- createEffect(() => {
610
- seen = [...items()];
611
- });
612
- batch(() => {
613
- items().splice(1, 2);
614
- });
615
- expect(seen).toEqual([1, 4]);
616
- });
617
-
618
- test("push + $derived recomputes", () => {
619
- const items = createSignal([1, 2, 3]);
620
- const doubled = createMemo(() => items().map((x) => x * 2));
621
- expect(doubled()).toEqual([2, 4, 6]);
622
- batch(() => {
623
- items().push(4);
624
- });
625
- expect(doubled()).toEqual([2, 4, 6, 8]);
626
- });
627
- });
628
-
629
- describe("proxy edge cases", () => {
630
- test("null is not proxied", () => {
631
- const state = createSignal(null);
632
- expect(typeof state).toBe("function");
633
- expect(state()).toBe(null);
634
- });
635
-
636
- test("nested arrays in objects are reactive", () => {
637
- const state = createSignal({ items: [1, 2, 3] });
638
- let seen: number[] = [];
639
- createEffect(() => {
640
- seen = [...state().items];
641
- });
642
- expect(seen).toEqual([1, 2, 3]);
643
- batch(() => {
644
- state().items.push(4);
645
- });
646
- expect(seen).toEqual([1, 2, 3, 4]);
647
- });
648
-
649
- test("multiple effects on same object, independent keys", () => {
650
- const state = createSignal({ x: 0, y: 0 });
651
- let xSeen = 0,
652
- ySeen = 0,
653
- xRuns = 0,
654
- yRuns = 0;
655
- createEffect(() => {
656
- xSeen = state().x;
657
- xRuns++;
658
- });
659
- createEffect(() => {
660
- ySeen = state().y;
661
- yRuns++;
662
- });
663
- expect(xRuns).toBe(1);
664
- expect(yRuns).toBe(1);
665
-
666
- batch(() => {
667
- state().x = 5;
668
- });
669
- expect(xSeen).toBe(5);
670
- expect(ySeen).toBe(0);
671
- expect(xRuns).toBe(2);
672
- expect(yRuns).toBe(1);
673
-
674
- batch(() => {
675
- state().y = 10;
676
- });
677
- expect(xSeen).toBe(5);
678
- expect(ySeen).toBe(10);
679
- expect(xRuns).toBe(2);
680
- expect(yRuns).toBe(2);
681
- });
682
- });
683
-
684
- describe("claim", () => {
685
- test("claims an element by tag name and removes from pool", () => {
686
- const div = document.createElement("div");
687
- const span = document.createElement("span");
688
- const pool: ChildNode[] = [div, span];
689
- const result = claim(pool, "div");
690
- expect(result).toBe(div);
691
- expect(pool).toEqual([span]);
692
- });
693
-
694
- test("falls back to createElement when no match", () => {
695
- const span = document.createElement("span");
696
- const pool: ChildNode[] = [span];
697
- const result = claim(pool, "div");
698
- expect(result.tagName).toBe("DIV");
699
- expect(pool).toEqual([span]);
700
- });
701
-
702
- test("claims first match only", () => {
703
- const d1 = document.createElement("div");
704
- const d2 = document.createElement("div");
705
- const pool: ChildNode[] = [d1, d2];
706
- const result = claim(pool, "div");
707
- expect(result).toBe(d1);
708
- expect(pool).toEqual([d2]);
709
- });
710
-
711
- test("is case-insensitive on tag comparison", () => {
712
- const svg = document.createElement("SVG");
713
- const pool: ChildNode[] = [svg];
714
- const result = claim(pool, "svg");
715
- expect(result).toBe(svg);
716
- });
717
-
718
- test("returns empty pool after claiming all matches", () => {
719
- const div = document.createElement("div");
720
- const pool: ChildNode[] = [div];
721
- claim(pool, "div");
722
- expect(pool).toEqual([]);
723
- });
724
-
725
- test("handles empty pool", () => {
726
- const pool: ChildNode[] = [];
727
- const result = claim(pool, "div");
728
- expect(result.tagName).toBe("DIV");
729
- expect(pool).toEqual([]);
730
- });
731
- });
732
-
733
- describe("claimText", () => {
734
- test("claims a text node from pool", () => {
735
- const text = document.createTextNode("hello");
736
- const div = document.createElement("div");
737
- const pool: ChildNode[] = [text, div];
738
- const result = claimText(pool);
739
- expect(result).toBe(text);
740
- expect(pool).toEqual([div]);
741
- });
742
-
743
- test("falls back to createTextNode when no text node in pool", () => {
744
- const div = document.createElement("div");
745
- const pool: ChildNode[] = [div];
746
- const result = claimText(pool);
747
- expect(result).toBeInstanceOf(Text);
748
- expect(result.textContent).toBe("");
749
- expect(pool).toEqual([div]);
750
- });
751
-
752
- test("handles empty pool", () => {
753
- const pool: ChildNode[] = [];
754
- const result = claimText(pool);
755
- expect(result).toBeInstanceOf(Text);
756
- expect(pool).toEqual([]);
757
- });
758
-
759
- test("claims first text node only", () => {
760
- const t1 = document.createTextNode("a");
761
- const t2 = document.createTextNode("b");
762
- const pool: ChildNode[] = [t1, t2];
763
- const result = claimText(pool);
764
- expect(result).toBe(t1);
765
- expect(pool).toEqual([t2]);
766
- });
767
- });
768
-
769
- describe("hydration: full element tree claim", () => {
770
- test("simulates SSR → hydration → scoped pools", () => {
771
- // Simulate SSR output: <div><h1>Title</h1><p>Body</p></div>
772
- const root = document.createElement("div");
773
- const h1 = document.createElement("h1");
774
- h1.appendChild(document.createTextNode("Title"));
775
- const p = document.createElement("p");
776
- p.appendChild(document.createTextNode("Body"));
777
- root.appendChild(h1);
778
- root.appendChild(p);
779
-
780
- // Hydration entry: claim root from page-level pool
781
- const pageNodes: ChildNode[] = [root];
782
- const div = claim(pageNodes, "div");
783
- expect(div).toBe(root);
784
-
785
- // Scope: root's children
786
- const childNodes = Array.from(div.childNodes);
787
-
788
- // Claim h1
789
- const claimedH1 = claim(childNodes, "h1");
790
- expect(claimedH1).toBe(h1);
791
- // Claim text inside h1 — removes from pool array, not DOM
792
- const h1Pool = Array.from(claimedH1.childNodes);
793
- const h1Text = claimText(h1Pool);
794
- expect(h1Text.textContent).toBe("Title");
795
- expect(h1Pool.length).toBe(0); // removed from pool
796
-
797
- // Claim p
798
- const claimedP = claim(childNodes, "p");
799
- expect(claimedP).toBe(p);
800
- const pPool = Array.from(claimedP.childNodes);
801
- const pText = claimText(pPool);
802
- expect(pText.textContent).toBe("Body");
803
-
804
- // No leftover nodes in parent pool
805
- expect(childNodes.length).toBe(0);
806
- });
807
-
808
- test("falls back gracefully for SSR/client mismatch", () => {
809
- // SSR rendered <div><span>OK</span></div>
810
- // Client expects <div><p>OK</p></div>
811
- const root = document.createElement("div");
812
- const span = document.createElement("span");
813
- span.appendChild(document.createTextNode("OK"));
814
- root.appendChild(span);
815
-
816
- const nodes = Array.from(root.childNodes);
817
- // Try to claim a <p> — doesn't exist, creates new one
818
- const p = claim(nodes, "p");
819
- expect(p.tagName).toBe("P");
820
- expect(p.childNodes.length).toBe(0); // new element has no children
821
- // Original span still in pool
822
- expect(nodes.length).toBe(1);
823
- expect(nodes[0]).toBe(span);
824
- });
825
- });
826
-
827
- describe("Scary reactivity", () => {
828
- test("effect unsubscribes from stale dependencies", () => {
829
- const condition = createSignal(true);
830
- const a = createSignal(1);
831
- const b = createSignal(1);
832
- let runs = 0;
833
-
834
- createEffect(() => {
835
- runs++;
836
- if (condition()) {
837
- a(); // read a only when condition is true
838
- } else {
839
- b(); // read b only when condition is false
840
- }
841
- });
842
-
843
- expect(runs).toBe(1);
844
-
845
- // flip condition, effect should now track b, not a
846
- batch(() => {
847
- condition.set(false);
848
- });
849
- expect(runs).toBe(2);
850
-
851
- // updating a should NOT trigger the effect anymore
852
- batch(() => {
853
- a.set(99);
854
- });
855
- expect(runs).toBe(2); // ← this will fail with current implementation
856
- });
857
- });
858
-
859
- describe("withEffectScope", () => {
860
- test("collects effects created during fn()", () => {
861
- const count = createSignal(0);
862
- let runs = 0;
863
- withEffectScope(() => {
864
- createEffect(() => {
865
- count();
866
- runs++;
867
- });
868
- });
869
- expect(runs).toBe(1);
870
- batch(() => {
871
- count.set(1);
872
- });
873
- expect(runs).toBe(2);
874
- });
875
-
876
- test("dispose stops all collected effects", () => {
877
- const count = createSignal(0);
878
- let runs = 0;
879
- const dispose = withEffectScope(() => {
880
- createEffect(() => {
881
- count();
882
- runs++;
883
- });
884
- });
885
- expect(runs).toBe(1);
886
- batch(() => {
887
- count.set(1);
888
- });
889
- expect(runs).toBe(2);
890
-
891
- dispose();
892
-
893
- batch(() => {
894
- count.set(2);
895
- });
896
- expect(runs).toBe(2); // stopped — no further runs
897
- });
898
-
899
- test("dispose stops multiple effects in the same scope", () => {
900
- const a = createSignal(0);
901
- const b = createSignal(0);
902
- let runsA = 0;
903
- let runsB = 0;
904
- const dispose = withEffectScope(() => {
905
- createEffect(() => {
906
- a();
907
- runsA++;
908
- });
909
- createEffect(() => {
910
- b();
911
- runsB++;
912
- });
913
- });
914
- batch(() => {
915
- a.set(1);
916
- b.set(1);
917
- });
918
- expect(runsA).toBe(2);
919
- expect(runsB).toBe(2);
920
-
921
- dispose();
922
-
923
- batch(() => {
924
- a.set(2);
925
- b.set(2);
926
- });
927
- expect(runsA).toBe(2);
928
- expect(runsB).toBe(2);
929
- });
930
-
931
- test("effects outside the scope are unaffected by dispose", () => {
932
- const count = createSignal(0);
933
- let outsideRuns = 0;
934
- let insideRuns = 0;
935
-
936
- createEffect(() => {
937
- count();
938
- outsideRuns++;
939
- });
940
-
941
- const dispose = withEffectScope(() => {
942
- createEffect(() => {
943
- count();
944
- insideRuns++;
945
- });
946
- });
947
-
948
- batch(() => {
949
- count.set(1);
950
- });
951
- expect(outsideRuns).toBe(2);
952
- expect(insideRuns).toBe(2);
953
-
954
- dispose();
955
-
956
- batch(() => {
957
- count.set(2);
958
- });
959
- expect(outsideRuns).toBe(3); // still running
960
- expect(insideRuns).toBe(2); // stopped
961
- });
962
-
963
- test("nested scopes: inner dispose doesn't affect outer", () => {
964
- const count = createSignal(0);
965
- let outerRuns = 0;
966
- let innerRuns = 0;
967
-
968
- const disposeOuter = withEffectScope(() => {
969
- createEffect(() => {
970
- count();
971
- outerRuns++;
972
- });
973
- // simulates a child component scope inside a page scope
974
- const disposeInner = withEffectScope(() => {
975
- createEffect(() => {
976
- count();
977
- innerRuns++;
978
- });
979
- });
980
- disposeInner();
981
- });
982
-
983
- batch(() => {
984
- count.set(1);
985
- });
986
- expect(outerRuns).toBe(2);
987
- expect(innerRuns).toBe(1); // stopped on inner dispose
988
-
989
- disposeOuter();
990
- batch(() => {
991
- count.set(2);
992
- });
993
- expect(outerRuns).toBe(2); // stopped on outer dispose
994
- });
995
-
996
- test("createMemo effects are collected in scope", () => {
997
- const count = createSignal(0);
998
- let memoRuns = 0;
999
- const dispose = withEffectScope(() => {
1000
- // createMemo internally calls createEffect
1001
- const doubled = createMemo(() => {
1002
- memoRuns++;
1003
- return count() * 2;
1004
- });
1005
- expect(doubled()).toBe(0);
1006
- });
1007
- expect(memoRuns).toBe(1);
1008
- batch(() => {
1009
- count.set(5);
1010
- });
1011
- expect(memoRuns).toBe(2);
1012
-
1013
- dispose();
1014
- batch(() => {
1015
- count.set(10);
1016
- });
1017
- expect(memoRuns).toBe(2); // memo's internal effect stopped
1018
- });
1019
-
1020
- test("dispose is idempotent", () => {
1021
- const count = createSignal(0);
1022
- let runs = 0;
1023
- const dispose = withEffectScope(() => {
1024
- createEffect(() => {
1025
- count();
1026
- runs++;
1027
- });
1028
- });
1029
- dispose();
1030
- dispose(); // second call must not throw or double-fire
1031
- batch(() => {
1032
- count.set(1);
1033
- });
1034
- expect(runs).toBe(1);
1035
- });
1036
- });
1037
-
1038
- describe("claimComment", () => {
1039
- test("claims a comment node from pool matching text", () => {
1040
- const comment = document.createComment("");
1041
- const div = document.createElement("div");
1042
- const pool: ChildNode[] = [comment, div];
1043
- const result = claimComment(pool);
1044
- expect(result).toBe(comment);
1045
- expect(pool).toEqual([div]);
1046
- });
1047
-
1048
- test("skips comments with non-matching data", () => {
1049
- const comment = document.createComment("g");
1050
- const div = document.createElement("div");
1051
- const pool: ChildNode[] = [comment, div];
1052
- const result = claimComment(pool, "");
1053
- expect(result).not.toBe(comment);
1054
- expect(result).toBeInstanceOf(Comment);
1055
- expect(pool).toEqual([comment, div]); // pool unchanged
1056
- });
1057
-
1058
- test("falls back to createComment when no comment in pool", () => {
1059
- const div = document.createElement("div");
1060
- const pool: ChildNode[] = [div];
1061
- const result = claimComment(pool);
1062
- expect(result).toBeInstanceOf(Comment);
1063
- expect(pool).toEqual([div]);
1064
- });
1065
-
1066
- test("handles empty pool", () => {
1067
- const pool: ChildNode[] = [];
1068
- const result = claimComment(pool);
1069
- expect(result).toBeInstanceOf(Comment);
1070
- expect(pool).toEqual([]);
1071
- });
1072
- });
1073
-
1074
- describe("hydrateKeyedList", () => {
1075
- test("claims anchor and sibling elements, builds keyMap", () => {
1076
- // Simulate SSR output: <!--g--> <li>A</li> <li>B</li> <!--/g-->
1077
- const parent = document.createElement("ul");
1078
- const open = document.createComment("g");
1079
- const li1 = document.createElement("li");
1080
- li1.textContent = "A";
1081
- const li2 = document.createElement("li");
1082
- li2.textContent = "B";
1083
- const close = document.createComment("/g");
1084
- parent.appendChild(open);
1085
- parent.appendChild(li1);
1086
- parent.appendChild(li2);
1087
- parent.appendChild(close);
1088
-
1089
- const pool: ChildNode[] = [open, li1, li2, close];
1090
- const items = [{ id: "a" }, { id: "b" }];
1091
-
1092
- const result = hydrateKeyedList(
1093
- pool,
1094
- items,
1095
- (item) => item.id,
1096
- (item) => {
1097
- const li = document.createElement("li");
1098
- li.textContent = item.id;
1099
- return li;
1100
- },
1101
- );
1102
-
1103
- expect(result.anchor).toBe(open);
1104
- expect(result.keyMap.size).toBe(2);
1105
- expect(result.keyMap.get("a")).toBe(li1);
1106
- expect(result.keyMap.get("b")).toBe(li2);
1107
- // Pool lost anchor (g) via claimComment.
1108
- // li nodes + /g remain in pool array (walk removes from DOM only)
1109
- expect(pool.length).toBe(3); // li1, li2, /g still in pool array
1110
- });
1111
-
1112
- test("handles SSR/client order mismatch by reconciling", () => {
1113
- // SSR rendered: A, B. Client expects: B, A.
1114
- const parent = document.createElement("ul");
1115
- const open = document.createComment("g");
1116
- const liA = document.createElement("li");
1117
- liA.textContent = "A";
1118
- const liB = document.createElement("li");
1119
- liB.textContent = "B";
1120
- const close = document.createComment("/g");
1121
- parent.appendChild(open);
1122
- parent.appendChild(liA);
1123
- parent.appendChild(liB);
1124
- parent.appendChild(close);
1125
-
1126
- // Pool has anchor + items in SSR order
1127
- const pool: ChildNode[] = [open, liA, liB, close];
1128
- // Client order is reversed
1129
- const items = [{ id: "b" }, { id: "a" }];
1130
-
1131
- const result = hydrateKeyedList(
1132
- pool,
1133
- items,
1134
- (item) => item.id,
1135
- (item) => {
1136
- const li = document.createElement("li");
1137
- li.textContent = item.id;
1138
- return li;
1139
- },
1140
- );
1141
-
1142
- // keyMap should have the claimed nodes (mapped by SSR position)
1143
- expect(result.keyMap.get("b")).toBe(liA); // first sibling → first signal item key
1144
- expect(result.keyMap.get("a")).toBe(liB); // second sibling → second signal item key
1145
- });
1146
-
1147
- test("empty signal items → empty keyMap", () => {
1148
- const parent = document.createElement("ul");
1149
- const open = document.createComment("g");
1150
- const close = document.createComment("/g");
1151
- parent.appendChild(open);
1152
- parent.appendChild(close);
1153
-
1154
- const pool: ChildNode[] = [open, close];
1155
- const result = hydrateKeyedList(
1156
- pool,
1157
- [],
1158
- (item: { id: string }) => item.id,
1159
- () => document.createElement("li"),
1160
- );
1161
-
1162
- expect(result.anchor).toBe(open);
1163
- expect(result.keyMap.size).toBe(0);
1164
- });
1165
- });
1166
-
1167
- describe("A1 createRawSignal", () => {
1168
- test("skips proxy for objects", () => {
1169
- const state = createRawSignal({ name: "Alice" });
1170
- expect(state()).toEqual({ name: "Alice" });
1171
- // Raw signal — direct object, not proxied
1172
- const obj = state();
1173
- state.set({ name: "Bob" });
1174
- expect(state()).toEqual({ name: "Bob" });
1175
- });
1176
-
1177
- test("works with primitives", () => {
1178
- const count = createRawSignal(0);
1179
- expect(count()).toBe(0);
1180
- count.set(5);
1181
- expect(count()).toBe(5);
1182
- });
1183
-
1184
- test("is reactive", () => {
1185
- const count = createRawSignal(0);
1186
- let runs = 0;
1187
- createEffect(() => {
1188
- count();
1189
- runs++;
1190
- });
1191
- expect(runs).toBe(1);
1192
- batch(() => {
1193
- count.set(1);
1194
- });
1195
- expect(runs).toBe(2);
1196
- });
1197
-
1198
- test(".peek() reads without tracking", () => {
1199
- const count = createRawSignal(0);
1200
- let runs = 0;
1201
- createEffect(() => {
1202
- count.peek();
1203
- runs++;
1204
- });
1205
- expect(runs).toBe(1);
1206
- batch(() => {
1207
- count.set(1);
1208
- });
1209
- expect(runs).toBe(1);
1210
- });
1211
- });
1212
-
1213
- describe("A2 snapshot", () => {
1214
- test("returns primitive values as-is", () => {
1215
- expect(snapshot(42)).toBe(42);
1216
- expect(snapshot("hello")).toBe("hello");
1217
- expect(snapshot(null)).toBe(null);
1218
- expect(snapshot(undefined)).toBe(undefined);
1219
- });
1220
-
1221
- test("unwraps proxy to plain object", () => {
1222
- const state = createSignal({ name: "Alice", age: 30 });
1223
- const snap = snapshot(state());
1224
- expect(snap).toEqual({ name: "Alice", age: 30 });
1225
- expect(snap).not.toBe(state()); // distinct reference
1226
- });
1227
-
1228
- test("unwraps proxy to plain array", () => {
1229
- const items = createSignal([1, 2, 3]);
1230
- const snap = snapshot(items());
1231
- expect(snap).toEqual([1, 2, 3]);
1232
- expect(Array.isArray(snap)).toBe(true);
1233
- expect(snap).not.toBe(items()); // distinct reference
1234
- });
1235
-
1236
- test("non-proxy objects are returned as-is", () => {
1237
- const obj = { plain: true };
1238
- expect(snapshot(obj)).toBe(obj);
1239
- });
1240
-
1241
- test("deeply nested snapshot is fully un-proxied", () => {
1242
- const obj = createSignal({ a: { b: { c: 1 } } });
1243
- const s = snapshot(obj());
1244
- expect(s.a.b.c).toBe(1);
1245
- // s.a.b should NOT be a proxy — it's a plain object from the deep copy
1246
- const proxyTargetCheck = (val: object) =>
1247
- typeof val === "object" &&
1248
- val !== null &&
1249
- !Object.getPrototypeOf(val)?.constructor?.name?.includes("Map");
1250
- // Verify no nested proxy: accessing s.a.b.c should not trigger tracking
1251
- let tracked = false;
1252
- const prev = (globalThis as any).__sigil_tracking_test;
1253
- (globalThis as any).__sigil_tracking_test = () => (tracked = true);
1254
- const raw = s.a.b;
1255
- // No reactive tracking should happen on plain objects
1256
- expect(raw.c).toBe(1);
1257
- // Deep compare confirms it's a full independent copy
1258
- expect(s).toEqual({ a: { b: { c: 1 } } });
1259
- expect(s.a).not.toBe(obj().a);
1260
- expect(s.a.b).not.toBe(obj().a.b);
1261
- });
1262
- });
1263
-
1264
- describe("A3 tracking", () => {
1265
- test("returns false outside effect", () => {
1266
- expect(tracking()).toBe(false);
1267
- });
1268
-
1269
- test("returns true inside effect", () => {
1270
- let inside = false;
1271
- createEffect(() => {
1272
- inside = tracking();
1273
- });
1274
- expect(inside).toBe(true);
1275
- });
1276
-
1277
- test("returns false after effect completes", () => {
1278
- createEffect(() => {
1279
- tracking();
1280
- });
1281
- expect(tracking()).toBe(false);
1282
- });
1283
- });
1284
-
1285
- describe("A4 createInspectEffect", () => {
1286
- test("does not throw", () => {
1287
- const count = createSignal(0);
1288
- expect(() => {
1289
- createInspectEffect(() => [count()]);
1290
- }).not.toThrow();
1291
- });
1292
-
1293
- test("accepts custom callback", () => {
1294
- const count = createSignal(0);
1295
- const logged: unknown[][] = [];
1296
- createInspectEffect(
1297
- () => [count()],
1298
- (vals) => logged.push(vals),
1299
- );
1300
- expect(logged.length).toBe(1);
1301
- expect(logged[0]).toEqual([0]);
1302
- });
1303
- });
1304
-
1305
- describe("A5 createEffectPre", () => {
1306
- test("runs immediately like createEffect", () => {
1307
- const count = createSignal(0);
1308
- let seen = -1;
1309
- createEffectPre(() => {
1310
- seen = count();
1311
- });
1312
- expect(seen).toBe(0);
1313
- });
1314
-
1315
- test("reruns on signal change", () => {
1316
- const count = createSignal(0);
1317
- let seen = -1;
1318
- createEffectPre(() => {
1319
- seen = count();
1320
- });
1321
- batch(() => {
1322
- count.set(5);
1323
- });
1324
- expect(seen).toBe(5);
1325
- });
1326
- });
1327
-
1328
- describe("A7 ReactiveMap", () => {
1329
- test("basic set/get", () => {
1330
- const map = new ReactiveMap<string, number>();
1331
- map.set("a", 1);
1332
- expect(map.get("a")).toBe(1);
1333
- });
1334
-
1335
- test("reactivity: get tracks reads", () => {
1336
- const map = new ReactiveMap<string, number>();
1337
- let size = 0;
1338
- createEffect(() => {
1339
- size = map.size;
1340
- });
1341
- expect(size).toBe(0);
1342
- batch(() => {
1343
- map.set("x", 1);
1344
- });
1345
- expect(size).toBe(1);
1346
- });
1347
-
1348
- test("deleted key triggers reactivity", () => {
1349
- const map = new ReactiveMap<string, number>();
1350
- map.set("a", 1);
1351
- let accessed: number | undefined;
1352
- createEffect(() => {
1353
- accessed = map.get("a");
1354
- });
1355
- expect(accessed).toBe(1);
1356
- batch(() => {
1357
- map.delete("a");
1358
- });
1359
- expect(accessed).toBe(undefined);
1360
- });
1361
-
1362
- test("clear triggers reactivity", () => {
1363
- const map = new ReactiveMap<string, number>();
1364
- map.set("a", 1);
1365
- let size = 0;
1366
- createEffect(() => {
1367
- size = map.size;
1368
- });
1369
- batch(() => {
1370
- map.clear();
1371
- });
1372
- expect(size).toBe(0);
1373
- });
1374
-
1375
- test("forEach tracks reads", () => {
1376
- const map = new ReactiveMap<string, number>();
1377
- map.set("a", 1);
1378
- let count = 0;
1379
- createEffect(() => {
1380
- count = 0;
1381
- map.forEach(() => count++);
1382
- });
1383
- expect(count).toBe(1);
1384
- batch(() => {
1385
- map.set("b", 2);
1386
- });
1387
- expect(count).toBe(2);
1388
- });
1389
-
1390
- test("per-key granularity: setting key X does not re-trigger effect reading key Y", () => {
1391
- const map = new ReactiveMap<string, number>();
1392
- map.set("a", 1);
1393
- map.set("b", 2);
1394
- let aRuns = 0;
1395
- let bRuns = 0;
1396
- createEffect(() => {
1397
- map.get("a");
1398
- aRuns++;
1399
- });
1400
- createEffect(() => {
1401
- map.get("b");
1402
- bRuns++;
1403
- });
1404
- expect(aRuns).toBe(1);
1405
- expect(bRuns).toBe(1);
1406
-
1407
- batch(() => {
1408
- map.set("a", 10); // only a-effect should rerun
1409
- });
1410
- expect(aRuns).toBe(2);
1411
- expect(bRuns).toBe(1);
1412
-
1413
- batch(() => {
1414
- map.set("b", 20); // only b-effect should rerun
1415
- });
1416
- expect(aRuns).toBe(2);
1417
- expect(bRuns).toBe(2);
1418
- });
1419
-
1420
- test("per-key: adding new key triggers struct but not existing key effects", () => {
1421
- const map = new ReactiveMap<string, number>();
1422
- map.set("a", 1);
1423
- let aRuns = 0;
1424
- let sizeRuns = 0;
1425
- createEffect(() => {
1426
- map.get("a");
1427
- aRuns++;
1428
- });
1429
- createEffect(() => {
1430
- map.size;
1431
- sizeRuns++;
1432
- });
1433
- expect(aRuns).toBe(1);
1434
- expect(sizeRuns).toBe(1);
1435
-
1436
- batch(() => {
1437
- map.set("b", 2); // new key — struct changes, a's value doesn't
1438
- });
1439
- expect(aRuns).toBe(1);
1440
- expect(sizeRuns).toBe(2);
1441
- });
1442
- });
1443
-
1444
- describe("A7 ReactiveSet", () => {
1445
- test("basic add/has", () => {
1446
- const set = new ReactiveSet<number>();
1447
- set.add(1);
1448
- expect(set.has(1)).toBe(true);
1449
- });
1450
-
1451
- test("reactivity: has tracks reads", () => {
1452
- const set = new ReactiveSet<number>();
1453
- let hasIt = false;
1454
- createEffect(() => {
1455
- hasIt = set.has(1);
1456
- });
1457
- expect(hasIt).toBe(false);
1458
- batch(() => {
1459
- set.add(1);
1460
- });
1461
- expect(hasIt).toBe(true);
1462
- });
1463
-
1464
- test("delete triggers reactivity", () => {
1465
- const set = new ReactiveSet<number>();
1466
- set.add(1);
1467
- let size = 0;
1468
- createEffect(() => {
1469
- size = set.size;
1470
- });
1471
- expect(size).toBe(1);
1472
- batch(() => {
1473
- set.delete(1);
1474
- });
1475
- expect(size).toBe(0);
1476
- });
1477
-
1478
- test("clear triggers reactivity", () => {
1479
- const set = new ReactiveSet<number>();
1480
- set.add(1);
1481
- let size = 0;
1482
- createEffect(() => {
1483
- size = set.size;
1484
- });
1485
- batch(() => {
1486
- set.clear();
1487
- });
1488
- expect(size).toBe(0);
1489
- });
1490
-
1491
- test("forEach tracks reads", () => {
1492
- const set = new ReactiveSet<string>();
1493
- set.add("a");
1494
- let count = 0;
1495
- createEffect(() => {
1496
- count = 0;
1497
- set.forEach(() => count++);
1498
- });
1499
- expect(count).toBe(1);
1500
- batch(() => {
1501
- set.add("b");
1502
- });
1503
- expect(count).toBe(2);
1504
- });
1505
- });
1506
-
1507
- describe("A8 easing functions", () => {
1508
- test("linear is identity", () => {
1509
- expect(linear(0)).toBe(0);
1510
- expect(linear(0.5)).toBe(0.5);
1511
- expect(linear(1)).toBe(1);
1512
- });
1513
-
1514
- test("easeInQuad is quadratic", () => {
1515
- expect(easeInQuad(0)).toBe(0);
1516
- expect(easeInQuad(0.5)).toBe(0.25);
1517
- expect(easeInQuad(1)).toBe(1);
1518
- });
1519
-
1520
- test("easeOutQuad completes at 1", () => {
1521
- expect(easeOutQuad(0)).toBe(0);
1522
- expect(easeOutQuad(1)).toBe(1);
1523
- });
1524
-
1525
- test("all easing functions return 0 at t=0 and 1 at t=1", () => {
1526
- const fns = [
1527
- easeInQuad,
1528
- easeOutQuad,
1529
- easeInOutQuad,
1530
- easeInCubic,
1531
- easeOutCubic,
1532
- easeInOutCubic,
1533
- easeInQuart,
1534
- easeOutQuart,
1535
- easeInOutQuart,
1536
- easeInQuint,
1537
- easeOutQuint,
1538
- easeInOutQuint,
1539
- easeInSine,
1540
- easeOutSine,
1541
- easeInOutSine,
1542
- ];
1543
- for (const fn of fns) {
1544
- expect(fn(0)).toBeCloseTo(0, 10);
1545
- expect(fn(1)).toBeCloseTo(1, 10);
1546
- }
1547
- });
1548
- });
1549
-
1550
- describe("A9 createProfilerEffect", () => {
1551
- test("runs the effect", () => {
1552
- let ran = false;
1553
- createProfilerEffect("test", () => {
1554
- ran = true;
1555
- });
1556
- expect(ran).toBe(true);
1557
- });
1558
-
1559
- test("reacts to signal changes", () => {
1560
- const count = createSignal(0);
1561
- let seen = -1;
1562
- createProfilerEffect("test", () => {
1563
- seen = count();
1564
- });
1565
- expect(seen).toBe(0);
1566
- batch(() => {
1567
- count.set(5);
1568
- });
1569
- expect(seen).toBe(5);
1570
- });
1571
- });
1572
-
1573
- describe("Array reactivity", () => {
1574
- test("push on $state array triggers reactivity", async () => {
1575
- const items = createSignal([] as string[]);
1576
- let count = 0;
1577
- createEffect(() => {
1578
- items(); // now auto-tracks length
1579
- count++;
1580
- });
1581
- await tick();
1582
- expect(count).toBe(1); // initial run
1583
- items().push("a");
1584
- await tick();
1585
- expect(count).toBe(2); // re-ran after push
1586
- expect(items().length).toBe(1);
1587
- });
1588
-
1589
- test("pop on $state array triggers reactivity", async () => {
1590
- const items = createSignal(["a", "b"] as string[]);
1591
- let len = 0;
1592
- createEffect(() => {
1593
- len = items().length; // reads length, subscribes
1594
- });
1595
- await tick();
1596
- expect(len).toBe(2);
1597
- items().pop();
1598
- await tick();
1599
- expect(len).toBe(1);
1600
- });
1601
-
1602
- test("splice on $state array triggers reactivity", async () => {
1603
- const items = createSignal(["a", "b", "c"] as string[]);
1604
- let len = 0;
1605
- createEffect(() => { len = items().length; });
1606
- await tick();
1607
- expect(len).toBe(3);
1608
- items().splice(1, 1);
1609
- await tick();
1610
- expect(len).toBe(2);
1611
- });
1612
- })
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ batch,
4
+ claim,
5
+ claimComment,
6
+ claimText,
7
+ createEffect,
8
+ createEffectPre,
9
+ createInspectEffect,
10
+ createMemo,
11
+ createProfilerEffect,
12
+ createRawSignal,
13
+ createSignal,
14
+ easeInCubic,
15
+ easeInOutCubic,
16
+ easeInOutQuad,
17
+ easeInOutQuart,
18
+ easeInOutQuint,
19
+ easeInOutSine,
20
+ easeInQuad,
21
+ easeInQuart,
22
+ easeInQuint,
23
+ easeInSine,
24
+ easeOutCubic,
25
+ easeOutQuad,
26
+ easeOutQuart,
27
+ easeOutQuint,
28
+ easeOutSine,
29
+ hydrateKeyedList,
30
+ linear,
31
+ ReactiveMap,
32
+ ReactiveSet,
33
+ reconcile,
34
+ snapshot,
35
+ tick,
36
+ tracking,
37
+ withEffectScope,
38
+ } from "./index.ts";
39
+
40
+ // Minimal DOM mock for reconcile tests
41
+ class MockNode {
42
+ parentNode: MockNode | null = null;
43
+ children: MockNode[] = [];
44
+ name: string;
45
+ constructor(name: string) {
46
+ this.name = name;
47
+ }
48
+ before(node: MockNode) {
49
+ // Remove from old parent
50
+ if (node.parentNode) {
51
+ const idx = node.parentNode.children.indexOf(node);
52
+ if (idx >= 0) node.parentNode.children.splice(idx, 1);
53
+ }
54
+ // Insert before this node in parent
55
+ if (this.parentNode) {
56
+ const idx = this.parentNode.children.indexOf(this);
57
+ this.parentNode.children.splice(idx, 0, node);
58
+ node.parentNode = this.parentNode;
59
+ }
60
+ }
61
+ removeChild(node: MockNode) {
62
+ const idx = this.children.indexOf(node);
63
+ if (idx >= 0) this.children.splice(idx, 1);
64
+ node.parentNode = null;
65
+ }
66
+ toString() {
67
+ return this.name;
68
+ }
69
+ }
70
+ class MockComment extends MockNode {
71
+ constructor() {
72
+ super("");
73
+ }
74
+ }
75
+ class MockElement extends MockNode {}
76
+
77
+ function nodeNames(parent: MockNode): string[] {
78
+ return parent.children.filter((c) => c.name !== "").map((c) => c.name);
79
+ }
80
+
81
+ describe("Runtime", () => {
82
+ describe("Basic reactivity", () => {
83
+ describe("createSignal", () => {
84
+ test("returns initial value", () => {
85
+ const count = createSignal(0);
86
+ expect(count()).toBe(0);
87
+ });
88
+
89
+ test("updates on set", () => {
90
+ const count = createSignal(0);
91
+ count.set(5);
92
+ expect(count()).toBe(5);
93
+ });
94
+ });
95
+
96
+ describe("createEffect", () => {
97
+ test("runs immediately", () => {
98
+ const count = createSignal(0);
99
+ let seen = -1;
100
+ createEffect(() => {
101
+ seen = count();
102
+ });
103
+ expect(seen).toBe(0);
104
+ });
105
+
106
+ test("reruns on signal change", () => {
107
+ const count = createSignal(0);
108
+ let seen = -1;
109
+ createEffect(() => {
110
+ seen = count();
111
+ });
112
+ batch(() => {
113
+ count.set(5);
114
+ });
115
+ expect(seen).toBe(5);
116
+ });
117
+ });
118
+
119
+ describe("createMemo", () => {
120
+ test("derives value", () => {
121
+ const count = createSignal(2);
122
+ const doubled = createMemo(() => count() * 2);
123
+ expect(doubled()).toBe(4);
124
+ });
125
+
126
+ test("updates when dependency changes", () => {
127
+ const count = createSignal(2);
128
+ const doubled = createMemo(() => count() * 2);
129
+ batch(() => {
130
+ count.set(5);
131
+ });
132
+ expect(doubled()).toBe(10);
133
+ });
134
+ });
135
+ });
136
+
137
+ describe("reconcile", () => {
138
+ function setup() {
139
+ const parent = new MockElement("parent");
140
+ const anchor = new MockComment();
141
+ parent.children.push(anchor);
142
+ anchor.parentNode = parent;
143
+ const keyMap = new Map<unknown, MockNode>();
144
+ return { parent, anchor, keyMap };
145
+ }
146
+
147
+ /** Cast wrapper — MockNode/MockElement satisfy the DOM APIs reconcile actually uses */
148
+ function mockReconcile<T>(
149
+ anchor: MockComment,
150
+ keyMap: Map<unknown, MockNode>,
151
+ newItems: T[],
152
+ getKey: (item: T) => unknown,
153
+ createNode: (item: T) => MockNode,
154
+ ) {
155
+ reconcile(
156
+ anchor as unknown as Comment,
157
+ keyMap as unknown as Map<unknown, Node>,
158
+ newItems,
159
+ getKey,
160
+ createNode as unknown as (item: T) => Node,
161
+ );
162
+ }
163
+
164
+ test("inserts initial items", () => {
165
+ const { parent, anchor, keyMap } = setup();
166
+ const items = [
167
+ { id: 1, text: "a" },
168
+ { id: 2, text: "b" },
169
+ ];
170
+
171
+ mockReconcile(
172
+ anchor,
173
+ keyMap,
174
+ items,
175
+ (i) => i.id,
176
+ (i) => new MockElement(i.text),
177
+ );
178
+
179
+ expect(nodeNames(parent)).toEqual(["a", "b"]);
180
+ expect(keyMap.size).toBe(2);
181
+ });
182
+
183
+ test("reuses nodes with same key", () => {
184
+ const { parent, anchor, keyMap } = setup();
185
+ const items = [
186
+ { id: 1, text: "a" },
187
+ { id: 2, text: "b" },
188
+ ];
189
+
190
+ mockReconcile(
191
+ anchor,
192
+ keyMap,
193
+ items,
194
+ (i) => i.id,
195
+ (i) => new MockElement(i.text),
196
+ );
197
+ const nodeA = keyMap.get(1);
198
+
199
+ mockReconcile(
200
+ anchor,
201
+ keyMap,
202
+ items,
203
+ (i) => i.id,
204
+ (i) => new MockElement(i.text),
205
+ );
206
+
207
+ expect(keyMap.get(1)).toBe(nodeA);
208
+ expect(nodeNames(parent)).toEqual(["a", "b"]);
209
+ });
210
+
211
+ test("removes deleted items", () => {
212
+ const { parent, anchor, keyMap } = setup();
213
+ mockReconcile(
214
+ anchor,
215
+ keyMap,
216
+ [{ id: 1 }, { id: 2 }, { id: 3 }],
217
+ (i) => i.id,
218
+ (i) => new MockElement(`n${i.id}`),
219
+ );
220
+ expect(nodeNames(parent)).toEqual(["n1", "n2", "n3"]);
221
+
222
+ mockReconcile(
223
+ anchor,
224
+ keyMap,
225
+ [{ id: 1 }, { id: 3 }],
226
+ (i) => i.id,
227
+ (i) => new MockElement(`n${i.id}`),
228
+ );
229
+
230
+ expect(nodeNames(parent)).toEqual(["n1", "n3"]);
231
+ expect(keyMap.has(2)).toBe(false);
232
+ });
233
+
234
+ test("moves nodes to new order", () => {
235
+ const { parent, anchor, keyMap } = setup();
236
+ mockReconcile(
237
+ anchor,
238
+ keyMap,
239
+ [{ id: 1 }, { id: 2 }, { id: 3 }],
240
+ (i) => i.id,
241
+ (i) => new MockElement(`n${i.id}`),
242
+ );
243
+ const node1 = keyMap.get(1);
244
+ const node2 = keyMap.get(2);
245
+ const node3 = keyMap.get(3);
246
+
247
+ mockReconcile(
248
+ anchor,
249
+ keyMap,
250
+ [{ id: 3 }, { id: 2 }, { id: 1 }],
251
+ (i) => i.id,
252
+ (i) => new MockElement(`n${i.id}`),
253
+ );
254
+
255
+ expect(keyMap.get(1)).toBe(node1);
256
+ expect(keyMap.get(2)).toBe(node2);
257
+ expect(keyMap.get(3)).toBe(node3);
258
+ expect(nodeNames(parent)).toEqual(["n3", "n2", "n1"]);
259
+ });
260
+
261
+ test("handles mixed add/remove/reorder", () => {
262
+ const { parent, anchor, keyMap } = setup();
263
+ mockReconcile(
264
+ anchor,
265
+ keyMap,
266
+ [{ id: 1 }, { id: 2 }, { id: 3 }],
267
+ (i) => i.id,
268
+ (i) => new MockElement(`n${i.id}`),
269
+ );
270
+ const node1 = keyMap.get(1);
271
+ const node3 = keyMap.get(3);
272
+
273
+ mockReconcile(
274
+ anchor,
275
+ keyMap,
276
+ [{ id: 4 }, { id: 3 }, { id: 1 }],
277
+ (i) => i.id,
278
+ (i) => new MockElement(`n${i.id}`),
279
+ );
280
+
281
+ expect(keyMap.has(2)).toBe(false);
282
+ expect(keyMap.get(1)).toBe(node1);
283
+ expect(keyMap.get(3)).toBe(node3);
284
+ expect(keyMap.has(4)).toBe(true);
285
+ expect(nodeNames(parent)).toEqual(["n4", "n3", "n1"]);
286
+ });
287
+
288
+ test("empty list removes all", () => {
289
+ const { parent, anchor, keyMap } = setup();
290
+ mockReconcile(
291
+ anchor,
292
+ keyMap,
293
+ [{ id: 1 }, { id: 2 }],
294
+ (i) => i.id,
295
+ (i) => new MockElement(`n${i.id}`),
296
+ );
297
+ expect(nodeNames(parent)).toEqual(["n1", "n2"]);
298
+
299
+ mockReconcile(
300
+ anchor,
301
+ keyMap,
302
+ [] as { id: number }[],
303
+ (i) => i.id,
304
+ (i) => new MockElement(`n${i.id}`),
305
+ );
306
+
307
+ expect(nodeNames(parent)).toEqual([]);
308
+ expect(keyMap.size).toBe(0);
309
+ });
310
+
311
+ test("from empty to populated", () => {
312
+ const { parent, anchor, keyMap } = setup();
313
+ expect(nodeNames(parent)).toEqual([]);
314
+
315
+ mockReconcile(
316
+ anchor,
317
+ keyMap,
318
+ [{ id: 1 }, { id: 2 }],
319
+ (i) => i.id,
320
+ (i) => new MockElement(`n${i.id}`),
321
+ );
322
+
323
+ expect(nodeNames(parent)).toEqual(["n1", "n2"]);
324
+ });
325
+
326
+ test("preserves anchor position", () => {
327
+ const { parent, anchor, keyMap } = setup();
328
+ const header = new MockElement("header");
329
+ anchor.before(header);
330
+
331
+ mockReconcile(
332
+ anchor,
333
+ keyMap,
334
+ [{ id: 1 }],
335
+ (i) => i.id,
336
+ () => new MockElement("item"),
337
+ );
338
+
339
+ expect(nodeNames(parent)).toEqual(["header", "item"]);
340
+ });
341
+ });
342
+
343
+ test("effect cleanup runs before rerun", () => {
344
+ const count = createSignal(0);
345
+ const log: string[] = [];
346
+ createEffect(() => {
347
+ count(); // subscribe
348
+ return () => log.push("cleanup");
349
+ });
350
+ count.set(1);
351
+ count.set(2);
352
+ batch(() => {});
353
+ expect(log).toEqual(["cleanup"]);
354
+ });
355
+
356
+ test("disposer stops effect", () => {
357
+ const count = createSignal(0);
358
+ let runs = 0;
359
+ const stop = createEffect(() => {
360
+ count();
361
+ runs++;
362
+ });
363
+ batch(() => {
364
+ count.set(1);
365
+ });
366
+ stop();
367
+ batch(() => {
368
+ count.set(2);
369
+ });
370
+ expect(runs).toBe(2); // initial + one update, not three
371
+ });
372
+
373
+ test("cleanup runs on dispose", () => {
374
+ let cleaned = false;
375
+ const stop = createEffect(() => () => {
376
+ cleaned = true;
377
+ });
378
+ stop();
379
+ expect(cleaned).toBe(true);
380
+ });
381
+ });
382
+
383
+ describe("batch", () => {
384
+ test("multiple signals in batch trigger effect once", () => {
385
+ const count = createSignal(0);
386
+ const name = createSignal("a");
387
+ let runs = 0;
388
+ createEffect(() => {
389
+ count();
390
+ name();
391
+ runs++;
392
+ });
393
+ expect(runs).toBe(1); // initial run
394
+
395
+ batch(() => {
396
+ count.set(1);
397
+ name.set("b");
398
+ });
399
+ expect(runs).toBe(2); // one run, not two
400
+ });
401
+
402
+ test("nested batches flush on outermost exit", () => {
403
+ const count = createSignal(0);
404
+ let runs = 0;
405
+ createEffect(() => {
406
+ count();
407
+ runs++;
408
+ });
409
+
410
+ batch(() => {
411
+ batch(() => {
412
+ count.set(1);
413
+ });
414
+ count.set(2); // still inside outer batch
415
+ });
416
+ expect(runs).toBe(2); // one run after outer exits
417
+ });
418
+
419
+ test("outside batch auto-batches to microtask", () => {
420
+ const count = createSignal(0);
421
+ let runs = 0;
422
+ createEffect(() => {
423
+ count();
424
+ runs++;
425
+ });
426
+
427
+ count.set(1);
428
+ count.set(2);
429
+ // Without batch, effects are auto-batched — no synchronous run
430
+ expect(runs).toBe(1); // initial only
431
+
432
+ batch(() => {});
433
+ expect(runs).toBe(2); // initial + one batched update (last write wins)
434
+ });
435
+ });
436
+
437
+ describe("createSignal with objects", () => {
438
+ test("property read returns value", () => {
439
+ const state = createSignal({ name: "Alice" });
440
+ expect(state().name).toBe("Alice");
441
+ });
442
+
443
+ test("property write triggers effect", () => {
444
+ const state = createSignal({ name: "Alice" });
445
+ let seen = "";
446
+ createEffect(() => {
447
+ seen = state().name;
448
+ });
449
+ expect(seen).toBe("Alice");
450
+ batch(() => {
451
+ state().name = "Bob";
452
+ });
453
+ expect(seen).toBe("Bob");
454
+ });
455
+
456
+ test("unrelated property change does not trigger effect", () => {
457
+ const state = createSignal({ name: "Alice", age: 30 });
458
+ let runs = 0;
459
+ createEffect(() => {
460
+ state().name;
461
+ runs++;
462
+ });
463
+ expect(runs).toBe(1);
464
+ batch(() => {
465
+ state().age = 31;
466
+ });
467
+ expect(runs).toBe(1);
468
+ batch(() => {
469
+ state().name = "Bob";
470
+ });
471
+ expect(runs).toBe(2);
472
+ });
473
+
474
+ test("deeply nested property access is reactive", () => {
475
+ const state = createSignal({ user: { address: { city: "NYC" } } });
476
+ let seen = "";
477
+ createEffect(() => {
478
+ seen = state().user.address.city;
479
+ });
480
+ expect(seen).toBe("NYC");
481
+ batch(() => {
482
+ state().user.address.city = "LA";
483
+ });
484
+ expect(seen).toBe("LA");
485
+ });
486
+
487
+ test("proxyCache deduplication — same proxy for repeated nested access", () => {
488
+ const state = createSignal({ address: { city: "Agartha" } });
489
+ const a1 = state().address;
490
+ const a2 = state().address;
491
+ expect(a1).toBe(a2);
492
+
493
+ let seen = "";
494
+ createEffect(() => {
495
+ seen = state().address.city;
496
+ });
497
+ expect(seen).toBe("Agartha");
498
+ batch(() => {
499
+ state().address.city = "Hyperborea";
500
+ });
501
+ expect(seen).toBe("Hyperborea");
502
+ });
503
+
504
+ test(".set() replaces entire object", () => {
505
+ const state = createSignal({ name: "Alice", age: 30 });
506
+ let seen = "";
507
+ createEffect(() => {
508
+ seen = state().name;
509
+ });
510
+ expect(seen).toBe("Alice");
511
+ batch(() => {
512
+ state.set({ name: "Charlie", age: 25 });
513
+ });
514
+ expect(seen).toBe("Charlie");
515
+ });
516
+
517
+ test(".peek() reads without tracking", () => {
518
+ const state = createSignal({ name: "Alice" });
519
+ let runs = 0;
520
+ createEffect(() => {
521
+ state.peek();
522
+ runs++;
523
+ });
524
+ expect(runs).toBe(1);
525
+ batch(() => {
526
+ state().name = "Bob";
527
+ });
528
+ expect(runs).toBe(1);
529
+ });
530
+
531
+ test("JSON.stringify works on proxy", () => {
532
+ const state = createSignal({ name: "Alice", age: 30 });
533
+ expect(JSON.stringify(state())).toBe('{"name":"Alice","age":30}');
534
+ });
535
+
536
+ test("Object.keys works on proxy", () => {
537
+ const state = createSignal({ name: "Alice", age: 30 });
538
+ expect(Object.keys(state())).toEqual(["name", "age"]);
539
+ });
540
+
541
+ test("spread works on proxy", () => {
542
+ const state = createSignal({ name: "Alice" });
543
+ const copy = { ...state() };
544
+ expect(copy).toEqual({ name: "Alice" });
545
+ });
546
+ });
547
+
548
+ describe("createSignal with arrays", () => {
549
+ test("length is reactive", () => {
550
+ const items = createSignal([1, 2, 3]);
551
+ let len = 0;
552
+ createEffect(() => {
553
+ len = items().length;
554
+ });
555
+ expect(len).toBe(3);
556
+ batch(() => {
557
+ items().push(4);
558
+ });
559
+ expect(len).toBe(4);
560
+ });
561
+
562
+ test("index access is reactive", () => {
563
+ const items = createSignal([1, 2, 3]);
564
+ let first = 0;
565
+ createEffect(() => {
566
+ //@ts-expect-error the assertion for that is below
567
+ first = items()[0];
568
+ });
569
+ expect(first).toBe(1);
570
+ batch(() => {
571
+ items()[0] = 99;
572
+ });
573
+ expect(first).toBe(99);
574
+ });
575
+
576
+ test("push triggers effects that read length", () => {
577
+ const items = createSignal<number[]>([]);
578
+ let seen: number[] = [];
579
+ createEffect(() => {
580
+ seen = [...items()];
581
+ });
582
+ expect(seen).toEqual([]);
583
+ batch(() => {
584
+ items().push(1);
585
+ });
586
+ expect(seen).toEqual([1]);
587
+ batch(() => {
588
+ items().push(2);
589
+ });
590
+ expect(seen).toEqual([1, 2]);
591
+ });
592
+
593
+ test("pop triggers length effect", () => {
594
+ const items = createSignal([1, 2, 3]);
595
+ let len = 0;
596
+ createEffect(() => {
597
+ len = items().length;
598
+ });
599
+ expect(len).toBe(3);
600
+ batch(() => {
601
+ items().pop();
602
+ });
603
+ expect(len).toBe(2);
604
+ });
605
+
606
+ test("splice triggers effects", () => {
607
+ const items = createSignal([1, 2, 3, 4]);
608
+ let seen: number[] = [];
609
+ createEffect(() => {
610
+ seen = [...items()];
611
+ });
612
+ batch(() => {
613
+ items().splice(1, 2);
614
+ });
615
+ expect(seen).toEqual([1, 4]);
616
+ });
617
+
618
+ test("push + $derived recomputes", () => {
619
+ const items = createSignal([1, 2, 3]);
620
+ const doubled = createMemo(() => items().map((x) => x * 2));
621
+ expect(doubled()).toEqual([2, 4, 6]);
622
+ batch(() => {
623
+ items().push(4);
624
+ });
625
+ expect(doubled()).toEqual([2, 4, 6, 8]);
626
+ });
627
+ });
628
+
629
+ describe("proxy edge cases", () => {
630
+ test("null is not proxied", () => {
631
+ const state = createSignal(null);
632
+ expect(typeof state).toBe("function");
633
+ expect(state()).toBe(null);
634
+ });
635
+
636
+ test("nested arrays in objects are reactive", () => {
637
+ const state = createSignal({ items: [1, 2, 3] });
638
+ let seen: number[] = [];
639
+ createEffect(() => {
640
+ seen = [...state().items];
641
+ });
642
+ expect(seen).toEqual([1, 2, 3]);
643
+ batch(() => {
644
+ state().items.push(4);
645
+ });
646
+ expect(seen).toEqual([1, 2, 3, 4]);
647
+ });
648
+
649
+ test("multiple effects on same object, independent keys", () => {
650
+ const state = createSignal({ x: 0, y: 0 });
651
+ let xSeen = 0,
652
+ ySeen = 0,
653
+ xRuns = 0,
654
+ yRuns = 0;
655
+ createEffect(() => {
656
+ xSeen = state().x;
657
+ xRuns++;
658
+ });
659
+ createEffect(() => {
660
+ ySeen = state().y;
661
+ yRuns++;
662
+ });
663
+ expect(xRuns).toBe(1);
664
+ expect(yRuns).toBe(1);
665
+
666
+ batch(() => {
667
+ state().x = 5;
668
+ });
669
+ expect(xSeen).toBe(5);
670
+ expect(ySeen).toBe(0);
671
+ expect(xRuns).toBe(2);
672
+ expect(yRuns).toBe(1);
673
+
674
+ batch(() => {
675
+ state().y = 10;
676
+ });
677
+ expect(xSeen).toBe(5);
678
+ expect(ySeen).toBe(10);
679
+ expect(xRuns).toBe(2);
680
+ expect(yRuns).toBe(2);
681
+ });
682
+ });
683
+
684
+ describe("claim", () => {
685
+ test("claims an element by tag name and removes from pool", () => {
686
+ const div = document.createElement("div");
687
+ const span = document.createElement("span");
688
+ const pool: ChildNode[] = [div, span];
689
+ const result = claim(pool, "div");
690
+ expect(result).toBe(div);
691
+ expect(pool).toEqual([span]);
692
+ });
693
+
694
+ test("falls back to createElement when no match", () => {
695
+ const span = document.createElement("span");
696
+ const pool: ChildNode[] = [span];
697
+ const result = claim(pool, "div");
698
+ expect(result.tagName).toBe("DIV");
699
+ expect(pool).toEqual([span]);
700
+ });
701
+
702
+ test("claims first match only", () => {
703
+ const d1 = document.createElement("div");
704
+ const d2 = document.createElement("div");
705
+ const pool: ChildNode[] = [d1, d2];
706
+ const result = claim(pool, "div");
707
+ expect(result).toBe(d1);
708
+ expect(pool).toEqual([d2]);
709
+ });
710
+
711
+ test("is case-insensitive on tag comparison", () => {
712
+ const svg = document.createElement("SVG");
713
+ const pool: ChildNode[] = [svg];
714
+ const result = claim(pool, "svg");
715
+ expect(result).toBe(svg);
716
+ });
717
+
718
+ test("returns empty pool after claiming all matches", () => {
719
+ const div = document.createElement("div");
720
+ const pool: ChildNode[] = [div];
721
+ claim(pool, "div");
722
+ expect(pool).toEqual([]);
723
+ });
724
+
725
+ test("handles empty pool", () => {
726
+ const pool: ChildNode[] = [];
727
+ const result = claim(pool, "div");
728
+ expect(result.tagName).toBe("DIV");
729
+ expect(pool).toEqual([]);
730
+ });
731
+ });
732
+
733
+ describe("claimText", () => {
734
+ test("claims a text node from pool", () => {
735
+ const text = document.createTextNode("hello");
736
+ const div = document.createElement("div");
737
+ const pool: ChildNode[] = [text, div];
738
+ const result = claimText(pool);
739
+ expect(result).toBe(text);
740
+ expect(pool).toEqual([div]);
741
+ });
742
+
743
+ test("falls back to createTextNode when no text node in pool", () => {
744
+ const div = document.createElement("div");
745
+ const pool: ChildNode[] = [div];
746
+ const result = claimText(pool);
747
+ expect(result).toBeInstanceOf(Text);
748
+ expect(result.textContent).toBe("");
749
+ expect(pool).toEqual([div]);
750
+ });
751
+
752
+ test("handles empty pool", () => {
753
+ const pool: ChildNode[] = [];
754
+ const result = claimText(pool);
755
+ expect(result).toBeInstanceOf(Text);
756
+ expect(pool).toEqual([]);
757
+ });
758
+
759
+ test("claims first text node only", () => {
760
+ const t1 = document.createTextNode("a");
761
+ const t2 = document.createTextNode("b");
762
+ const pool: ChildNode[] = [t1, t2];
763
+ const result = claimText(pool);
764
+ expect(result).toBe(t1);
765
+ expect(pool).toEqual([t2]);
766
+ });
767
+ });
768
+
769
+ describe("hydration: full element tree claim", () => {
770
+ test("simulates SSR → hydration → scoped pools", () => {
771
+ // Simulate SSR output: <div><h1>Title</h1><p>Body</p></div>
772
+ const root = document.createElement("div");
773
+ const h1 = document.createElement("h1");
774
+ h1.appendChild(document.createTextNode("Title"));
775
+ const p = document.createElement("p");
776
+ p.appendChild(document.createTextNode("Body"));
777
+ root.appendChild(h1);
778
+ root.appendChild(p);
779
+
780
+ // Hydration entry: claim root from page-level pool
781
+ const pageNodes: ChildNode[] = [root];
782
+ const div = claim(pageNodes, "div");
783
+ expect(div).toBe(root);
784
+
785
+ // Scope: root's children
786
+ const childNodes = Array.from(div.childNodes);
787
+
788
+ // Claim h1
789
+ const claimedH1 = claim(childNodes, "h1");
790
+ expect(claimedH1).toBe(h1);
791
+ // Claim text inside h1 — removes from pool array, not DOM
792
+ const h1Pool = Array.from(claimedH1.childNodes);
793
+ const h1Text = claimText(h1Pool);
794
+ expect(h1Text.textContent).toBe("Title");
795
+ expect(h1Pool.length).toBe(0); // removed from pool
796
+
797
+ // Claim p
798
+ const claimedP = claim(childNodes, "p");
799
+ expect(claimedP).toBe(p);
800
+ const pPool = Array.from(claimedP.childNodes);
801
+ const pText = claimText(pPool);
802
+ expect(pText.textContent).toBe("Body");
803
+
804
+ // No leftover nodes in parent pool
805
+ expect(childNodes.length).toBe(0);
806
+ });
807
+
808
+ test("falls back gracefully for SSR/client mismatch", () => {
809
+ // SSR rendered <div><span>OK</span></div>
810
+ // Client expects <div><p>OK</p></div>
811
+ const root = document.createElement("div");
812
+ const span = document.createElement("span");
813
+ span.appendChild(document.createTextNode("OK"));
814
+ root.appendChild(span);
815
+
816
+ const nodes = Array.from(root.childNodes);
817
+ // Try to claim a <p> — doesn't exist, creates new one
818
+ const p = claim(nodes, "p");
819
+ expect(p.tagName).toBe("P");
820
+ expect(p.childNodes.length).toBe(0); // new element has no children
821
+ // Original span still in pool
822
+ expect(nodes.length).toBe(1);
823
+ expect(nodes[0]).toBe(span);
824
+ });
825
+ });
826
+
827
+ describe("Scary reactivity", () => {
828
+ test("effect unsubscribes from stale dependencies", () => {
829
+ const condition = createSignal(true);
830
+ const a = createSignal(1);
831
+ const b = createSignal(1);
832
+ let runs = 0;
833
+
834
+ createEffect(() => {
835
+ runs++;
836
+ if (condition()) {
837
+ a(); // read a only when condition is true
838
+ } else {
839
+ b(); // read b only when condition is false
840
+ }
841
+ });
842
+
843
+ expect(runs).toBe(1);
844
+
845
+ // flip condition, effect should now track b, not a
846
+ batch(() => {
847
+ condition.set(false);
848
+ });
849
+ expect(runs).toBe(2);
850
+
851
+ // updating a should NOT trigger the effect anymore
852
+ batch(() => {
853
+ a.set(99);
854
+ });
855
+ expect(runs).toBe(2); // ← this will fail with current implementation
856
+ });
857
+ });
858
+
859
+ describe("withEffectScope", () => {
860
+ test("collects effects created during fn()", () => {
861
+ const count = createSignal(0);
862
+ let runs = 0;
863
+ withEffectScope(() => {
864
+ createEffect(() => {
865
+ count();
866
+ runs++;
867
+ });
868
+ });
869
+ expect(runs).toBe(1);
870
+ batch(() => {
871
+ count.set(1);
872
+ });
873
+ expect(runs).toBe(2);
874
+ });
875
+
876
+ test("dispose stops all collected effects", () => {
877
+ const count = createSignal(0);
878
+ let runs = 0;
879
+ const dispose = withEffectScope(() => {
880
+ createEffect(() => {
881
+ count();
882
+ runs++;
883
+ });
884
+ });
885
+ expect(runs).toBe(1);
886
+ batch(() => {
887
+ count.set(1);
888
+ });
889
+ expect(runs).toBe(2);
890
+
891
+ dispose();
892
+
893
+ batch(() => {
894
+ count.set(2);
895
+ });
896
+ expect(runs).toBe(2); // stopped — no further runs
897
+ });
898
+
899
+ test("dispose stops multiple effects in the same scope", () => {
900
+ const a = createSignal(0);
901
+ const b = createSignal(0);
902
+ let runsA = 0;
903
+ let runsB = 0;
904
+ const dispose = withEffectScope(() => {
905
+ createEffect(() => {
906
+ a();
907
+ runsA++;
908
+ });
909
+ createEffect(() => {
910
+ b();
911
+ runsB++;
912
+ });
913
+ });
914
+ batch(() => {
915
+ a.set(1);
916
+ b.set(1);
917
+ });
918
+ expect(runsA).toBe(2);
919
+ expect(runsB).toBe(2);
920
+
921
+ dispose();
922
+
923
+ batch(() => {
924
+ a.set(2);
925
+ b.set(2);
926
+ });
927
+ expect(runsA).toBe(2);
928
+ expect(runsB).toBe(2);
929
+ });
930
+
931
+ test("effects outside the scope are unaffected by dispose", () => {
932
+ const count = createSignal(0);
933
+ let outsideRuns = 0;
934
+ let insideRuns = 0;
935
+
936
+ createEffect(() => {
937
+ count();
938
+ outsideRuns++;
939
+ });
940
+
941
+ const dispose = withEffectScope(() => {
942
+ createEffect(() => {
943
+ count();
944
+ insideRuns++;
945
+ });
946
+ });
947
+
948
+ batch(() => {
949
+ count.set(1);
950
+ });
951
+ expect(outsideRuns).toBe(2);
952
+ expect(insideRuns).toBe(2);
953
+
954
+ dispose();
955
+
956
+ batch(() => {
957
+ count.set(2);
958
+ });
959
+ expect(outsideRuns).toBe(3); // still running
960
+ expect(insideRuns).toBe(2); // stopped
961
+ });
962
+
963
+ test("nested scopes: inner dispose doesn't affect outer", () => {
964
+ const count = createSignal(0);
965
+ let outerRuns = 0;
966
+ let innerRuns = 0;
967
+
968
+ const disposeOuter = withEffectScope(() => {
969
+ createEffect(() => {
970
+ count();
971
+ outerRuns++;
972
+ });
973
+ // simulates a child component scope inside a page scope
974
+ const disposeInner = withEffectScope(() => {
975
+ createEffect(() => {
976
+ count();
977
+ innerRuns++;
978
+ });
979
+ });
980
+ disposeInner();
981
+ });
982
+
983
+ batch(() => {
984
+ count.set(1);
985
+ });
986
+ expect(outerRuns).toBe(2);
987
+ expect(innerRuns).toBe(1); // stopped on inner dispose
988
+
989
+ disposeOuter();
990
+ batch(() => {
991
+ count.set(2);
992
+ });
993
+ expect(outerRuns).toBe(2); // stopped on outer dispose
994
+ });
995
+
996
+ test("createMemo effects are collected in scope", () => {
997
+ const count = createSignal(0);
998
+ let memoRuns = 0;
999
+ const dispose = withEffectScope(() => {
1000
+ // createMemo internally calls createEffect
1001
+ const doubled = createMemo(() => {
1002
+ memoRuns++;
1003
+ return count() * 2;
1004
+ });
1005
+ expect(doubled()).toBe(0);
1006
+ });
1007
+ expect(memoRuns).toBe(1);
1008
+ batch(() => {
1009
+ count.set(5);
1010
+ });
1011
+ expect(memoRuns).toBe(2);
1012
+
1013
+ dispose();
1014
+ batch(() => {
1015
+ count.set(10);
1016
+ });
1017
+ expect(memoRuns).toBe(2); // memo's internal effect stopped
1018
+ });
1019
+
1020
+ test("dispose is idempotent", () => {
1021
+ const count = createSignal(0);
1022
+ let runs = 0;
1023
+ const dispose = withEffectScope(() => {
1024
+ createEffect(() => {
1025
+ count();
1026
+ runs++;
1027
+ });
1028
+ });
1029
+ dispose();
1030
+ dispose(); // second call must not throw or double-fire
1031
+ batch(() => {
1032
+ count.set(1);
1033
+ });
1034
+ expect(runs).toBe(1);
1035
+ });
1036
+ });
1037
+
1038
+ describe("claimComment", () => {
1039
+ test("claims a comment node from pool matching text", () => {
1040
+ const comment = document.createComment("");
1041
+ const div = document.createElement("div");
1042
+ const pool: ChildNode[] = [comment, div];
1043
+ const result = claimComment(pool);
1044
+ expect(result).toBe(comment);
1045
+ expect(pool).toEqual([div]);
1046
+ });
1047
+
1048
+ test("skips comments with non-matching data", () => {
1049
+ const comment = document.createComment("g");
1050
+ const div = document.createElement("div");
1051
+ const pool: ChildNode[] = [comment, div];
1052
+ const result = claimComment(pool, "");
1053
+ expect(result).not.toBe(comment);
1054
+ expect(result).toBeInstanceOf(Comment);
1055
+ expect(pool).toEqual([comment, div]); // pool unchanged
1056
+ });
1057
+
1058
+ test("falls back to createComment when no comment in pool", () => {
1059
+ const div = document.createElement("div");
1060
+ const pool: ChildNode[] = [div];
1061
+ const result = claimComment(pool);
1062
+ expect(result).toBeInstanceOf(Comment);
1063
+ expect(pool).toEqual([div]);
1064
+ });
1065
+
1066
+ test("handles empty pool", () => {
1067
+ const pool: ChildNode[] = [];
1068
+ const result = claimComment(pool);
1069
+ expect(result).toBeInstanceOf(Comment);
1070
+ expect(pool).toEqual([]);
1071
+ });
1072
+ });
1073
+
1074
+ describe("hydrateKeyedList", () => {
1075
+ test("claims anchor and sibling elements, builds keyMap", () => {
1076
+ // Simulate SSR output: <!--g--> <li>A</li> <li>B</li> <!--/g-->
1077
+ const parent = document.createElement("ul");
1078
+ const open = document.createComment("g");
1079
+ const li1 = document.createElement("li");
1080
+ li1.textContent = "A";
1081
+ const li2 = document.createElement("li");
1082
+ li2.textContent = "B";
1083
+ const close = document.createComment("/g");
1084
+ parent.appendChild(open);
1085
+ parent.appendChild(li1);
1086
+ parent.appendChild(li2);
1087
+ parent.appendChild(close);
1088
+
1089
+ const pool: ChildNode[] = [open, li1, li2, close];
1090
+ const items = [{ id: "a" }, { id: "b" }];
1091
+
1092
+ const result = hydrateKeyedList(
1093
+ pool,
1094
+ items,
1095
+ (item) => item.id,
1096
+ (item) => {
1097
+ const li = document.createElement("li");
1098
+ li.textContent = item.id;
1099
+ return li;
1100
+ },
1101
+ );
1102
+
1103
+ expect(result.anchor).toBe(open);
1104
+ expect(result.keyMap.size).toBe(2);
1105
+ expect(result.keyMap.get("a")).toBe(li1);
1106
+ expect(result.keyMap.get("b")).toBe(li2);
1107
+ // Pool lost anchor (g) via claimComment.
1108
+ // li nodes + /g remain in pool array (walk removes from DOM only)
1109
+ expect(pool.length).toBe(3); // li1, li2, /g still in pool array
1110
+ });
1111
+
1112
+ test("handles SSR/client order mismatch by reconciling", () => {
1113
+ // SSR rendered: A, B. Client expects: B, A.
1114
+ const parent = document.createElement("ul");
1115
+ const open = document.createComment("g");
1116
+ const liA = document.createElement("li");
1117
+ liA.textContent = "A";
1118
+ const liB = document.createElement("li");
1119
+ liB.textContent = "B";
1120
+ const close = document.createComment("/g");
1121
+ parent.appendChild(open);
1122
+ parent.appendChild(liA);
1123
+ parent.appendChild(liB);
1124
+ parent.appendChild(close);
1125
+
1126
+ // Pool has anchor + items in SSR order
1127
+ const pool: ChildNode[] = [open, liA, liB, close];
1128
+ // Client order is reversed
1129
+ const items = [{ id: "b" }, { id: "a" }];
1130
+
1131
+ const result = hydrateKeyedList(
1132
+ pool,
1133
+ items,
1134
+ (item) => item.id,
1135
+ (item) => {
1136
+ const li = document.createElement("li");
1137
+ li.textContent = item.id;
1138
+ return li;
1139
+ },
1140
+ );
1141
+
1142
+ // keyMap should have the claimed nodes (mapped by SSR position)
1143
+ expect(result.keyMap.get("b")).toBe(liA); // first sibling → first signal item key
1144
+ expect(result.keyMap.get("a")).toBe(liB); // second sibling → second signal item key
1145
+ });
1146
+
1147
+ test("empty signal items → empty keyMap", () => {
1148
+ const parent = document.createElement("ul");
1149
+ const open = document.createComment("g");
1150
+ const close = document.createComment("/g");
1151
+ parent.appendChild(open);
1152
+ parent.appendChild(close);
1153
+
1154
+ const pool: ChildNode[] = [open, close];
1155
+ const result = hydrateKeyedList(
1156
+ pool,
1157
+ [],
1158
+ (item: { id: string }) => item.id,
1159
+ () => document.createElement("li"),
1160
+ );
1161
+
1162
+ expect(result.anchor).toBe(open);
1163
+ expect(result.keyMap.size).toBe(0);
1164
+ });
1165
+ });
1166
+
1167
+ describe("A1 createRawSignal", () => {
1168
+ test("skips proxy for objects", () => {
1169
+ const state = createRawSignal({ name: "Alice" });
1170
+ expect(state()).toEqual({ name: "Alice" });
1171
+ // Raw signal — direct object, not proxied
1172
+ const obj = state();
1173
+ state.set({ name: "Bob" });
1174
+ expect(state()).toEqual({ name: "Bob" });
1175
+ });
1176
+
1177
+ test("works with primitives", () => {
1178
+ const count = createRawSignal(0);
1179
+ expect(count()).toBe(0);
1180
+ count.set(5);
1181
+ expect(count()).toBe(5);
1182
+ });
1183
+
1184
+ test("is reactive", () => {
1185
+ const count = createRawSignal(0);
1186
+ let runs = 0;
1187
+ createEffect(() => {
1188
+ count();
1189
+ runs++;
1190
+ });
1191
+ expect(runs).toBe(1);
1192
+ batch(() => {
1193
+ count.set(1);
1194
+ });
1195
+ expect(runs).toBe(2);
1196
+ });
1197
+
1198
+ test(".peek() reads without tracking", () => {
1199
+ const count = createRawSignal(0);
1200
+ let runs = 0;
1201
+ createEffect(() => {
1202
+ count.peek();
1203
+ runs++;
1204
+ });
1205
+ expect(runs).toBe(1);
1206
+ batch(() => {
1207
+ count.set(1);
1208
+ });
1209
+ expect(runs).toBe(1);
1210
+ });
1211
+ });
1212
+
1213
+ describe("A2 snapshot", () => {
1214
+ test("returns primitive values as-is", () => {
1215
+ expect(snapshot(42)).toBe(42);
1216
+ expect(snapshot("hello")).toBe("hello");
1217
+ expect(snapshot(null)).toBe(null);
1218
+ expect(snapshot(undefined)).toBe(undefined);
1219
+ });
1220
+
1221
+ test("unwraps proxy to plain object", () => {
1222
+ const state = createSignal({ name: "Alice", age: 30 });
1223
+ const snap = snapshot(state());
1224
+ expect(snap).toEqual({ name: "Alice", age: 30 });
1225
+ expect(snap).not.toBe(state()); // distinct reference
1226
+ });
1227
+
1228
+ test("unwraps proxy to plain array", () => {
1229
+ const items = createSignal([1, 2, 3]);
1230
+ const snap = snapshot(items());
1231
+ expect(snap).toEqual([1, 2, 3]);
1232
+ expect(Array.isArray(snap)).toBe(true);
1233
+ expect(snap).not.toBe(items()); // distinct reference
1234
+ });
1235
+
1236
+ test("non-proxy objects are returned as-is", () => {
1237
+ const obj = { plain: true };
1238
+ expect(snapshot(obj)).toBe(obj);
1239
+ });
1240
+
1241
+ test("deeply nested snapshot is fully un-proxied", () => {
1242
+ const obj = createSignal({ a: { b: { c: 1 } } });
1243
+ const s = snapshot(obj());
1244
+ expect(s.a.b.c).toBe(1);
1245
+ // s.a.b should NOT be a proxy — it's a plain object from the deep copy
1246
+ //biome-ignore lint: shut up
1247
+ const proxyTargetCheck = (val: object) =>
1248
+ typeof val === "object" &&
1249
+ val !== null &&
1250
+ !Object.getPrototypeOf(val)?.constructor?.name?.includes("Map");
1251
+ // Verify no nested proxy: accessing s.a.b.c should not trigger tracking
1252
+ let tracked = false;
1253
+ //biome-ignore lint: shut up
1254
+ const prev = (globalThis as any).__sigil_tracking_test;
1255
+ (globalThis as any).__sigil_tracking_test = () => (tracked = true);
1256
+ const raw = s.a.b;
1257
+ // No reactive tracking should happen on plain objects
1258
+ expect(raw.c).toBe(1);
1259
+ // Deep compare confirms it's a full independent copy
1260
+ expect(s).toEqual({ a: { b: { c: 1 } } });
1261
+ expect(s.a).not.toBe(obj().a);
1262
+ expect(s.a.b).not.toBe(obj().a.b);
1263
+ });
1264
+ });
1265
+
1266
+ describe("A3 tracking", () => {
1267
+ test("returns false outside effect", () => {
1268
+ expect(tracking()).toBe(false);
1269
+ });
1270
+
1271
+ test("returns true inside effect", () => {
1272
+ let inside = false;
1273
+ createEffect(() => {
1274
+ inside = tracking();
1275
+ });
1276
+ expect(inside).toBe(true);
1277
+ });
1278
+
1279
+ test("returns false after effect completes", () => {
1280
+ createEffect(() => {
1281
+ tracking();
1282
+ });
1283
+ expect(tracking()).toBe(false);
1284
+ });
1285
+ });
1286
+
1287
+ describe("A4 createInspectEffect", () => {
1288
+ test("does not throw", () => {
1289
+ const count = createSignal(0);
1290
+ expect(() => {
1291
+ createInspectEffect(() => [count()]);
1292
+ }).not.toThrow();
1293
+ });
1294
+
1295
+ test("accepts custom callback", () => {
1296
+ const count = createSignal(0);
1297
+ const logged: unknown[][] = [];
1298
+ createInspectEffect(
1299
+ () => [count()],
1300
+ (vals) => logged.push(vals),
1301
+ );
1302
+ expect(logged.length).toBe(1);
1303
+ expect(logged[0]).toEqual([0]);
1304
+ });
1305
+ });
1306
+
1307
+ describe("A5 createEffectPre", () => {
1308
+ test("runs immediately like createEffect", () => {
1309
+ const count = createSignal(0);
1310
+ let seen = -1;
1311
+ createEffectPre(() => {
1312
+ seen = count();
1313
+ });
1314
+ expect(seen).toBe(0);
1315
+ });
1316
+
1317
+ test("reruns on signal change", () => {
1318
+ const count = createSignal(0);
1319
+ let seen = -1;
1320
+ createEffectPre(() => {
1321
+ seen = count();
1322
+ });
1323
+ batch(() => {
1324
+ count.set(5);
1325
+ });
1326
+ expect(seen).toBe(5);
1327
+ });
1328
+ });
1329
+
1330
+ describe("A7 ReactiveMap", () => {
1331
+ test("basic set/get", () => {
1332
+ const map = new ReactiveMap<string, number>();
1333
+ map.set("a", 1);
1334
+ expect(map.get("a")).toBe(1);
1335
+ });
1336
+
1337
+ test("reactivity: get tracks reads", () => {
1338
+ const map = new ReactiveMap<string, number>();
1339
+ let size = 0;
1340
+ createEffect(() => {
1341
+ size = map.size;
1342
+ });
1343
+ expect(size).toBe(0);
1344
+ batch(() => {
1345
+ map.set("x", 1);
1346
+ });
1347
+ expect(size).toBe(1);
1348
+ });
1349
+
1350
+ test("deleted key triggers reactivity", () => {
1351
+ const map = new ReactiveMap<string, number>();
1352
+ map.set("a", 1);
1353
+ let accessed: number | undefined;
1354
+ createEffect(() => {
1355
+ accessed = map.get("a");
1356
+ });
1357
+ expect(accessed).toBe(1);
1358
+ batch(() => {
1359
+ map.delete("a");
1360
+ });
1361
+ expect(accessed).toBe(undefined);
1362
+ });
1363
+
1364
+ test("clear triggers reactivity", () => {
1365
+ const map = new ReactiveMap<string, number>();
1366
+ map.set("a", 1);
1367
+ let size = 0;
1368
+ createEffect(() => {
1369
+ size = map.size;
1370
+ });
1371
+ batch(() => {
1372
+ map.clear();
1373
+ });
1374
+ expect(size).toBe(0);
1375
+ });
1376
+
1377
+ test("forEach tracks reads", () => {
1378
+ const map = new ReactiveMap<string, number>();
1379
+ map.set("a", 1);
1380
+ let count = 0;
1381
+ createEffect(() => {
1382
+ count = 0;
1383
+ //biome-ignore lint: shut up
1384
+ map.forEach(() => count++);
1385
+ });
1386
+ expect(count).toBe(1);
1387
+ batch(() => {
1388
+ map.set("b", 2);
1389
+ });
1390
+ expect(count).toBe(2);
1391
+ });
1392
+
1393
+ test("per-key granularity: setting key X does not re-trigger effect reading key Y", () => {
1394
+ const map = new ReactiveMap<string, number>();
1395
+ map.set("a", 1);
1396
+ map.set("b", 2);
1397
+ let aRuns = 0;
1398
+ let bRuns = 0;
1399
+ createEffect(() => {
1400
+ map.get("a");
1401
+ aRuns++;
1402
+ });
1403
+ createEffect(() => {
1404
+ map.get("b");
1405
+ bRuns++;
1406
+ });
1407
+ expect(aRuns).toBe(1);
1408
+ expect(bRuns).toBe(1);
1409
+
1410
+ batch(() => {
1411
+ map.set("a", 10); // only a-effect should rerun
1412
+ });
1413
+ expect(aRuns).toBe(2);
1414
+ expect(bRuns).toBe(1);
1415
+
1416
+ batch(() => {
1417
+ map.set("b", 20); // only b-effect should rerun
1418
+ });
1419
+ expect(aRuns).toBe(2);
1420
+ expect(bRuns).toBe(2);
1421
+ });
1422
+
1423
+ test("per-key: adding new key triggers struct but not existing key effects", () => {
1424
+ const map = new ReactiveMap<string, number>();
1425
+ map.set("a", 1);
1426
+ let aRuns = 0;
1427
+ let sizeRuns = 0;
1428
+ createEffect(() => {
1429
+ map.get("a");
1430
+ aRuns++;
1431
+ });
1432
+ createEffect(() => {
1433
+ map.size;
1434
+ sizeRuns++;
1435
+ });
1436
+ expect(aRuns).toBe(1);
1437
+ expect(sizeRuns).toBe(1);
1438
+
1439
+ batch(() => {
1440
+ map.set("b", 2); // new key — struct changes, a's value doesn't
1441
+ });
1442
+ expect(aRuns).toBe(1);
1443
+ expect(sizeRuns).toBe(2);
1444
+ });
1445
+ });
1446
+
1447
+ describe("A7 ReactiveSet", () => {
1448
+ test("basic add/has", () => {
1449
+ const set = new ReactiveSet<number>();
1450
+ set.add(1);
1451
+ expect(set.has(1)).toBe(true);
1452
+ });
1453
+
1454
+ test("reactivity: has tracks reads", () => {
1455
+ const set = new ReactiveSet<number>();
1456
+ let hasIt = false;
1457
+ createEffect(() => {
1458
+ hasIt = set.has(1);
1459
+ });
1460
+ expect(hasIt).toBe(false);
1461
+ batch(() => {
1462
+ set.add(1);
1463
+ });
1464
+ expect(hasIt).toBe(true);
1465
+ });
1466
+
1467
+ test("delete triggers reactivity", () => {
1468
+ const set = new ReactiveSet<number>();
1469
+ set.add(1);
1470
+ let size = 0;
1471
+ createEffect(() => {
1472
+ size = set.size;
1473
+ });
1474
+ expect(size).toBe(1);
1475
+ batch(() => {
1476
+ set.delete(1);
1477
+ });
1478
+ expect(size).toBe(0);
1479
+ });
1480
+
1481
+ test("clear triggers reactivity", () => {
1482
+ const set = new ReactiveSet<number>();
1483
+ set.add(1);
1484
+ let size = 0;
1485
+ createEffect(() => {
1486
+ size = set.size;
1487
+ });
1488
+ batch(() => {
1489
+ set.clear();
1490
+ });
1491
+ expect(size).toBe(0);
1492
+ });
1493
+
1494
+ test("forEach tracks reads", () => {
1495
+ const set = new ReactiveSet<string>();
1496
+ set.add("a");
1497
+ let count = 0;
1498
+ createEffect(() => {
1499
+ count = 0;
1500
+ //biome-ignore lint: shut up
1501
+ set.forEach(() => count++);
1502
+ });
1503
+ expect(count).toBe(1);
1504
+ batch(() => {
1505
+ set.add("b");
1506
+ });
1507
+ expect(count).toBe(2);
1508
+ });
1509
+ });
1510
+
1511
+ describe("A8 easing functions", () => {
1512
+ test("linear is identity", () => {
1513
+ expect(linear(0)).toBe(0);
1514
+ expect(linear(0.5)).toBe(0.5);
1515
+ expect(linear(1)).toBe(1);
1516
+ });
1517
+
1518
+ test("easeInQuad is quadratic", () => {
1519
+ expect(easeInQuad(0)).toBe(0);
1520
+ expect(easeInQuad(0.5)).toBe(0.25);
1521
+ expect(easeInQuad(1)).toBe(1);
1522
+ });
1523
+
1524
+ test("easeOutQuad completes at 1", () => {
1525
+ expect(easeOutQuad(0)).toBe(0);
1526
+ expect(easeOutQuad(1)).toBe(1);
1527
+ });
1528
+
1529
+ test("all easing functions return 0 at t=0 and 1 at t=1", () => {
1530
+ const fns = [
1531
+ easeInQuad,
1532
+ easeOutQuad,
1533
+ easeInOutQuad,
1534
+ easeInCubic,
1535
+ easeOutCubic,
1536
+ easeInOutCubic,
1537
+ easeInQuart,
1538
+ easeOutQuart,
1539
+ easeInOutQuart,
1540
+ easeInQuint,
1541
+ easeOutQuint,
1542
+ easeInOutQuint,
1543
+ easeInSine,
1544
+ easeOutSine,
1545
+ easeInOutSine,
1546
+ ];
1547
+ for (const fn of fns) {
1548
+ expect(fn(0)).toBeCloseTo(0, 10);
1549
+ expect(fn(1)).toBeCloseTo(1, 10);
1550
+ }
1551
+ });
1552
+ });
1553
+
1554
+ describe("A9 createProfilerEffect", () => {
1555
+ test("runs the effect", () => {
1556
+ let ran = false;
1557
+ createProfilerEffect("test", () => {
1558
+ ran = true;
1559
+ });
1560
+ expect(ran).toBe(true);
1561
+ });
1562
+
1563
+ test("reacts to signal changes", () => {
1564
+ const count = createSignal(0);
1565
+ let seen = -1;
1566
+ createProfilerEffect("test", () => {
1567
+ seen = count();
1568
+ });
1569
+ expect(seen).toBe(0);
1570
+ batch(() => {
1571
+ count.set(5);
1572
+ });
1573
+ expect(seen).toBe(5);
1574
+ });
1575
+ });
1576
+
1577
+ describe("Array reactivity", () => {
1578
+ test("push on $state array triggers reactivity", async () => {
1579
+ const items = createSignal([] as string[]);
1580
+ let count = 0;
1581
+ createEffect(() => {
1582
+ items(); // now auto-tracks length
1583
+ count++;
1584
+ });
1585
+ await tick();
1586
+ expect(count).toBe(1); // initial run
1587
+ items().push("a");
1588
+ await tick();
1589
+ expect(count).toBe(2); // re-ran after push
1590
+ expect(items().length).toBe(1);
1591
+ });
1592
+
1593
+ test("pop on $state array triggers reactivity", async () => {
1594
+ const items = createSignal(["a", "b"] as string[]);
1595
+ let len = 0;
1596
+ createEffect(() => {
1597
+ len = items().length; // reads length, subscribes
1598
+ });
1599
+ await tick();
1600
+ expect(len).toBe(2);
1601
+ items().pop();
1602
+ await tick();
1603
+ expect(len).toBe(1);
1604
+ });
1605
+
1606
+ test("splice on $state array triggers reactivity", async () => {
1607
+ const items = createSignal(["a", "b", "c"] as string[]);
1608
+ let len = 0;
1609
+ createEffect(() => { len = items().length; });
1610
+ await tick();
1611
+ expect(len).toBe(3);
1612
+ items().splice(1, 1);
1613
+ await tick();
1614
+ expect(len).toBe(2);
1615
+ });
1616
+ })