@sigil-dev/runtime 0.7.5 → 0.7.6

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