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