@sigil-dev/runtime 0.9.2 → 0.9.4

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