@sigil-dev/runtime 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.test.ts ADDED
@@ -0,0 +1,1041 @@
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(() => { a(); runsA++; });
824
+ createEffect(() => { b(); runsB++; });
825
+ });
826
+ a.set(1);
827
+ b.set(1);
828
+ expect(runsA).toBe(2);
829
+ expect(runsB).toBe(2);
830
+
831
+ dispose();
832
+
833
+ a.set(2);
834
+ b.set(2);
835
+ expect(runsA).toBe(2);
836
+ expect(runsB).toBe(2);
837
+ });
838
+
839
+ test("effects outside the scope are unaffected by dispose", () => {
840
+ const count = createSignal(0);
841
+ let outsideRuns = 0;
842
+ let insideRuns = 0;
843
+
844
+ createEffect(() => { count(); outsideRuns++; });
845
+
846
+ const dispose = withEffectScope(() => {
847
+ createEffect(() => { count(); insideRuns++; });
848
+ });
849
+
850
+ count.set(1);
851
+ expect(outsideRuns).toBe(2);
852
+ expect(insideRuns).toBe(2);
853
+
854
+ dispose();
855
+
856
+ count.set(2);
857
+ expect(outsideRuns).toBe(3); // still running
858
+ expect(insideRuns).toBe(2); // stopped
859
+ });
860
+
861
+ test("nested scopes: inner dispose doesn't affect outer", () => {
862
+ const count = createSignal(0);
863
+ let outerRuns = 0;
864
+ let innerRuns = 0;
865
+
866
+ const disposeOuter = withEffectScope(() => {
867
+ createEffect(() => { count(); outerRuns++; });
868
+ // simulates a child component scope inside a page scope
869
+ const disposeInner = withEffectScope(() => {
870
+ createEffect(() => { count(); innerRuns++; });
871
+ });
872
+ disposeInner();
873
+ });
874
+
875
+ count.set(1);
876
+ expect(outerRuns).toBe(2);
877
+ expect(innerRuns).toBe(1); // stopped on inner dispose
878
+
879
+ disposeOuter();
880
+ count.set(2);
881
+ expect(outerRuns).toBe(2); // stopped on outer dispose
882
+ });
883
+
884
+ test("createMemo effects are collected in scope", () => {
885
+ const count = createSignal(0);
886
+ let memoRuns = 0;
887
+ const dispose = withEffectScope(() => {
888
+ // createMemo internally calls createEffect
889
+ const doubled = createMemo(() => { memoRuns++; return count() * 2; });
890
+ expect(doubled()).toBe(0);
891
+ });
892
+ expect(memoRuns).toBe(1);
893
+ count.set(5);
894
+ expect(memoRuns).toBe(2);
895
+
896
+ dispose();
897
+ count.set(10);
898
+ expect(memoRuns).toBe(2); // memo's internal effect stopped
899
+ });
900
+
901
+ test("dispose is idempotent", () => {
902
+ const count = createSignal(0);
903
+ let runs = 0;
904
+ const dispose = withEffectScope(() => {
905
+ createEffect(() => { count(); runs++; });
906
+ });
907
+ dispose();
908
+ dispose(); // second call must not throw or double-fire
909
+ count.set(1);
910
+ expect(runs).toBe(1);
911
+ });
912
+ });
913
+
914
+ describe("claimComment", () => {
915
+ test("claims a comment node from pool matching text", () => {
916
+ const comment = document.createComment("");
917
+ const div = document.createElement("div");
918
+ const pool: ChildNode[] = [comment, div];
919
+ const result = claimComment(pool);
920
+ expect(result).toBe(comment);
921
+ expect(pool).toEqual([div]);
922
+ });
923
+
924
+ test("skips comments with non-matching data", () => {
925
+ const comment = document.createComment("g");
926
+ const div = document.createElement("div");
927
+ const pool: ChildNode[] = [comment, div];
928
+ const result = claimComment(pool, "");
929
+ expect(result).not.toBe(comment);
930
+ expect(result).toBeInstanceOf(Comment);
931
+ expect(pool).toEqual([comment, div]); // pool unchanged
932
+ });
933
+
934
+ test("falls back to createComment when no comment in pool", () => {
935
+ const div = document.createElement("div");
936
+ const pool: ChildNode[] = [div];
937
+ const result = claimComment(pool);
938
+ expect(result).toBeInstanceOf(Comment);
939
+ expect(pool).toEqual([div]);
940
+ });
941
+
942
+ test("handles empty pool", () => {
943
+ const pool: ChildNode[] = [];
944
+ const result = claimComment(pool);
945
+ expect(result).toBeInstanceOf(Comment);
946
+ expect(pool).toEqual([]);
947
+ });
948
+ });
949
+
950
+ describe("hydrateKeyedList", () => {
951
+ test("claims anchor and sibling elements, builds keyMap", () => {
952
+ // Simulate SSR output: <!--g--> <li>A</li> <li>B</li> <!--/g-->
953
+ const parent = document.createElement("ul");
954
+ const open = document.createComment("g");
955
+ const li1 = document.createElement("li");
956
+ li1.textContent = "A";
957
+ const li2 = document.createElement("li");
958
+ li2.textContent = "B";
959
+ const close = document.createComment("/g");
960
+ parent.appendChild(open);
961
+ parent.appendChild(li1);
962
+ parent.appendChild(li2);
963
+ parent.appendChild(close);
964
+
965
+ const pool: ChildNode[] = [open, li1, li2, close];
966
+ const items = [{ id: "a" }, { id: "b" }];
967
+
968
+ const result = hydrateKeyedList(
969
+ pool,
970
+ items,
971
+ (item) => item.id,
972
+ (item) => {
973
+ const li = document.createElement("li");
974
+ li.textContent = item.id;
975
+ return li;
976
+ },
977
+ );
978
+
979
+ expect(result.anchor).toBe(open);
980
+ expect(result.keyMap.size).toBe(2);
981
+ expect(result.keyMap.get("a")).toBe(li1);
982
+ expect(result.keyMap.get("b")).toBe(li2);
983
+ // Pool lost anchor (g) via claimComment.
984
+ // li nodes + /g remain in pool array (walk removes from DOM only)
985
+ expect(pool.length).toBe(3); // li1, li2, /g still in pool array
986
+ });
987
+
988
+ test("handles SSR/client order mismatch by reconciling", () => {
989
+ // SSR rendered: A, B. Client expects: B, A.
990
+ const parent = document.createElement("ul");
991
+ const open = document.createComment("g");
992
+ const liA = document.createElement("li");
993
+ liA.textContent = "A";
994
+ const liB = document.createElement("li");
995
+ liB.textContent = "B";
996
+ const close = document.createComment("/g");
997
+ parent.appendChild(open);
998
+ parent.appendChild(liA);
999
+ parent.appendChild(liB);
1000
+ parent.appendChild(close);
1001
+
1002
+ // Pool has anchor + items in SSR order
1003
+ const pool: ChildNode[] = [open, liA, liB, close];
1004
+ // Client order is reversed
1005
+ const items = [{ id: "b" }, { id: "a" }];
1006
+
1007
+ const result = hydrateKeyedList(
1008
+ pool,
1009
+ items,
1010
+ (item) => item.id,
1011
+ (item) => {
1012
+ const li = document.createElement("li");
1013
+ li.textContent = item.id;
1014
+ return li;
1015
+ },
1016
+ );
1017
+
1018
+ // keyMap should have the claimed nodes (mapped by SSR position)
1019
+ expect(result.keyMap.get("b")).toBe(liA); // first sibling → first signal item key
1020
+ expect(result.keyMap.get("a")).toBe(liB); // second sibling → second signal item key
1021
+ });
1022
+
1023
+ test("empty signal items → empty keyMap", () => {
1024
+ const parent = document.createElement("ul");
1025
+ const open = document.createComment("g");
1026
+ const close = document.createComment("/g");
1027
+ parent.appendChild(open);
1028
+ parent.appendChild(close);
1029
+
1030
+ const pool: ChildNode[] = [open, close];
1031
+ const result = hydrateKeyedList(
1032
+ pool,
1033
+ [],
1034
+ (item: { id: string }) => item.id,
1035
+ () => document.createElement("li"),
1036
+ );
1037
+
1038
+ expect(result.anchor).toBe(open);
1039
+ expect(result.keyMap.size).toBe(0);
1040
+ });
1041
+ });