@cplieger/reactive 1.0.0 → 1.0.1
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/jsr.json +8 -12
- package/package.json +6 -2
- package/src/invariants.test.ts +483 -0
- package/src/reactive.test.ts +1571 -0
- package/src/redteam.test.ts +552 -0
- package/src/redteam2.test.ts +711 -0
- package/src/redteam3.test.ts +771 -0
- package/src/redteam4.test.ts +410 -0
- package/src/redteam5.test.ts +692 -0
- package/src/redteam6.test.ts +650 -0
- package/src/redteam7.test.ts +658 -0
- package/src/redteam8-adversarial.test.ts +1084 -0
- package/.editorconfig +0 -19
- package/.htmlvalidate.json +0 -28
- package/.stylelintrc.json +0 -67
- package/cliff.toml +0 -37
- package/eslint.config.base.mjs +0 -187
|
@@ -0,0 +1,1571 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
signal,
|
|
5
|
+
effect,
|
|
6
|
+
batch,
|
|
7
|
+
computed,
|
|
8
|
+
createStore,
|
|
9
|
+
reconcile,
|
|
10
|
+
KEY_ATTR,
|
|
11
|
+
patch,
|
|
12
|
+
trackHandler,
|
|
13
|
+
untracked,
|
|
14
|
+
subscribe,
|
|
15
|
+
isSignal,
|
|
16
|
+
isComputed,
|
|
17
|
+
setEffectErrorHandler,
|
|
18
|
+
on,
|
|
19
|
+
} from "./index.js";
|
|
20
|
+
import type { ReconcileSpec, ReadonlySignal } from "./index.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// signal + effect + batch
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe("signal", () => {
|
|
27
|
+
it("reads and writes value", () => {
|
|
28
|
+
expect.assertions(2);
|
|
29
|
+
const s = signal(0);
|
|
30
|
+
expect(s.value).toBe(0);
|
|
31
|
+
s.value = 5;
|
|
32
|
+
expect(s.peek()).toBe(5);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("no-op on same value (Object.is)", () => {
|
|
36
|
+
expect.assertions(1);
|
|
37
|
+
const s = signal(1);
|
|
38
|
+
const spy = vi.fn();
|
|
39
|
+
effect(() => {
|
|
40
|
+
void s.value;
|
|
41
|
+
spy();
|
|
42
|
+
});
|
|
43
|
+
spy.mockClear();
|
|
44
|
+
s.value = 1;
|
|
45
|
+
expect(spy).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("NaN equality", () => {
|
|
49
|
+
expect.assertions(1);
|
|
50
|
+
const s = signal(NaN);
|
|
51
|
+
const spy = vi.fn();
|
|
52
|
+
effect(() => {
|
|
53
|
+
void s.value;
|
|
54
|
+
spy();
|
|
55
|
+
});
|
|
56
|
+
spy.mockClear();
|
|
57
|
+
s.value = NaN;
|
|
58
|
+
expect(spy).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("effect", () => {
|
|
63
|
+
it("runs immediately on creation", () => {
|
|
64
|
+
expect.assertions(1);
|
|
65
|
+
const spy = vi.fn();
|
|
66
|
+
effect(() => {
|
|
67
|
+
spy();
|
|
68
|
+
});
|
|
69
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("re-runs when dependency changes", () => {
|
|
73
|
+
expect.assertions(2);
|
|
74
|
+
const s = signal("a");
|
|
75
|
+
const spy = vi.fn();
|
|
76
|
+
effect(() => {
|
|
77
|
+
spy(s.value);
|
|
78
|
+
});
|
|
79
|
+
expect(spy).toHaveBeenCalledWith("a");
|
|
80
|
+
s.value = "b";
|
|
81
|
+
expect(spy).toHaveBeenCalledWith("b");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("tracks dynamic deps", () => {
|
|
85
|
+
expect.assertions(1);
|
|
86
|
+
const cond = signal(true);
|
|
87
|
+
const a = signal(1);
|
|
88
|
+
const b = signal(2);
|
|
89
|
+
const spy = vi.fn();
|
|
90
|
+
effect(() => {
|
|
91
|
+
spy(cond.value ? a.value : b.value);
|
|
92
|
+
});
|
|
93
|
+
spy.mockClear();
|
|
94
|
+
// b is not tracked when cond=true
|
|
95
|
+
b.value = 99;
|
|
96
|
+
expect(spy).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("cleanup runs before re-execution", () => {
|
|
100
|
+
expect.assertions(2);
|
|
101
|
+
const s = signal(0);
|
|
102
|
+
const order: string[] = [];
|
|
103
|
+
effect(() => {
|
|
104
|
+
const v = s.value;
|
|
105
|
+
order.push(`run:${v}`);
|
|
106
|
+
return () => {
|
|
107
|
+
order.push(`cleanup:${v}`);
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
s.value = 1;
|
|
111
|
+
expect(order).toEqual(["run:0", "cleanup:0", "run:1"]);
|
|
112
|
+
expect(order.length).toBe(3);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("disposal stops re-runs and calls cleanup", () => {
|
|
116
|
+
expect.assertions(2);
|
|
117
|
+
const s = signal(0);
|
|
118
|
+
let cleaned = false;
|
|
119
|
+
const dispose = effect(() => {
|
|
120
|
+
void s.value;
|
|
121
|
+
return () => {
|
|
122
|
+
cleaned = true;
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
dispose();
|
|
126
|
+
expect(cleaned).toBe(true);
|
|
127
|
+
s.value = 1;
|
|
128
|
+
// Should not have re-run (no error, no extra call)
|
|
129
|
+
expect(cleaned).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not leak stale subscriptions on re-run", () => {
|
|
133
|
+
expect.assertions(1);
|
|
134
|
+
const a = signal(1);
|
|
135
|
+
const b = signal(2);
|
|
136
|
+
const cond = signal(true);
|
|
137
|
+
const spy = vi.fn();
|
|
138
|
+
effect(() => {
|
|
139
|
+
spy(cond.value ? a.value : b.value);
|
|
140
|
+
});
|
|
141
|
+
// Switch to b
|
|
142
|
+
cond.value = false;
|
|
143
|
+
spy.mockClear();
|
|
144
|
+
// a should no longer trigger
|
|
145
|
+
a.value = 99;
|
|
146
|
+
expect(spy).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("batch", () => {
|
|
151
|
+
it("coalesces multiple signal writes", () => {
|
|
152
|
+
expect.assertions(2);
|
|
153
|
+
const s = signal(0);
|
|
154
|
+
const spy = vi.fn();
|
|
155
|
+
effect(() => {
|
|
156
|
+
spy(s.value);
|
|
157
|
+
});
|
|
158
|
+
spy.mockClear();
|
|
159
|
+
batch(() => {
|
|
160
|
+
s.value = 1;
|
|
161
|
+
s.value = 2;
|
|
162
|
+
s.value = 3;
|
|
163
|
+
});
|
|
164
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(spy).toHaveBeenCalledWith(3);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("nested batch flushes only after outermost", () => {
|
|
169
|
+
expect.assertions(2);
|
|
170
|
+
const s = signal(0);
|
|
171
|
+
const spy = vi.fn();
|
|
172
|
+
effect(() => {
|
|
173
|
+
spy(s.value);
|
|
174
|
+
});
|
|
175
|
+
spy.mockClear();
|
|
176
|
+
batch(() => {
|
|
177
|
+
batch(() => {
|
|
178
|
+
s.value = 1;
|
|
179
|
+
});
|
|
180
|
+
// Still inside outer batch — effect should not have run
|
|
181
|
+
expect(spy).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
// batch flushes synchronously at end of outermost batch
|
|
184
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("flushes synchronously (no MessageChannel)", () => {
|
|
188
|
+
expect.assertions(1);
|
|
189
|
+
const s = signal(0);
|
|
190
|
+
const results: number[] = [];
|
|
191
|
+
effect(() => {
|
|
192
|
+
results.push(s.value);
|
|
193
|
+
});
|
|
194
|
+
batch(() => {
|
|
195
|
+
s.value = 42;
|
|
196
|
+
});
|
|
197
|
+
// Effect should have already run synchronously
|
|
198
|
+
expect(results).toEqual([0, 42]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// createStore
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe("createStore", () => {
|
|
207
|
+
it("get/set round-trip", () => {
|
|
208
|
+
expect.assertions(1);
|
|
209
|
+
const { get, set } = createStore<{ x: number }>();
|
|
210
|
+
set("x", 42);
|
|
211
|
+
expect(get("x")).toBe(42);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("subscribe fires on change", () => {
|
|
215
|
+
expect.assertions(2);
|
|
216
|
+
const { set, subscribe } = createStore<{ v: string }>();
|
|
217
|
+
const spy = vi.fn();
|
|
218
|
+
subscribe("v", spy);
|
|
219
|
+
set("v", "hello");
|
|
220
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(spy).toHaveBeenCalledWith("hello");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("subscribe does not fire on same value", () => {
|
|
225
|
+
expect.assertions(1);
|
|
226
|
+
const { set, subscribe } = createStore<{ v: string }>();
|
|
227
|
+
set("v", "same");
|
|
228
|
+
const spy = vi.fn();
|
|
229
|
+
subscribe("v", spy);
|
|
230
|
+
set("v", "same");
|
|
231
|
+
expect(spy).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("unsubscribe stops notifications", () => {
|
|
235
|
+
expect.assertions(1);
|
|
236
|
+
const { set, subscribe } = createStore<{ v: number }>();
|
|
237
|
+
const spy = vi.fn();
|
|
238
|
+
const unsub = subscribe("v", spy);
|
|
239
|
+
set("v", 1);
|
|
240
|
+
unsub();
|
|
241
|
+
set("v", 2);
|
|
242
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("batch coalesces", () => {
|
|
246
|
+
expect.assertions(2);
|
|
247
|
+
const store = createStore<{ a: number; b: number }>();
|
|
248
|
+
const spyA = vi.fn();
|
|
249
|
+
const spyB = vi.fn();
|
|
250
|
+
store.subscribe("a", spyA);
|
|
251
|
+
store.subscribe("b", spyB);
|
|
252
|
+
store.batch(() => {
|
|
253
|
+
store.set("a", 1);
|
|
254
|
+
store.set("b", 2);
|
|
255
|
+
store.set("a", 10);
|
|
256
|
+
});
|
|
257
|
+
expect(spyA).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(spyA).toHaveBeenCalledWith(10);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("effect auto-tracks keys", () => {
|
|
262
|
+
expect.assertions(2);
|
|
263
|
+
const { get, set, effect: eff } = createStore<{ x: number; y: number }>();
|
|
264
|
+
set("x", 0);
|
|
265
|
+
set("y", 0);
|
|
266
|
+
const spy = vi.fn();
|
|
267
|
+
eff(() => {
|
|
268
|
+
spy(get("x"));
|
|
269
|
+
});
|
|
270
|
+
spy.mockClear();
|
|
271
|
+
set("x", 1);
|
|
272
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
273
|
+
spy.mockClear();
|
|
274
|
+
// y not tracked
|
|
275
|
+
set("y", 1);
|
|
276
|
+
expect(spy).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("effect cleanup runs on re-execution", () => {
|
|
280
|
+
expect.assertions(1);
|
|
281
|
+
const { get, set, effect: eff } = createStore<{ n: number }>();
|
|
282
|
+
set("n", 0);
|
|
283
|
+
const order: string[] = [];
|
|
284
|
+
eff(() => {
|
|
285
|
+
const v = get("n");
|
|
286
|
+
order.push(`run:${v}`);
|
|
287
|
+
return () => {
|
|
288
|
+
order.push(`cleanup:${v}`);
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
set("n", 1);
|
|
292
|
+
expect(order).toEqual(["run:0", "cleanup:0", "run:1"]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("effect disposal", () => {
|
|
296
|
+
expect.assertions(1);
|
|
297
|
+
const { get, set, effect: eff } = createStore<{ n: number }>();
|
|
298
|
+
set("n", 0);
|
|
299
|
+
const spy = vi.fn();
|
|
300
|
+
const dispose = eff(() => {
|
|
301
|
+
spy(get("n"));
|
|
302
|
+
});
|
|
303
|
+
spy.mockClear();
|
|
304
|
+
dispose();
|
|
305
|
+
set("n", 1);
|
|
306
|
+
expect(spy).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("computed updates output key", () => {
|
|
310
|
+
expect.assertions(2);
|
|
311
|
+
const { get, set, computed: comp } = createStore<{ a: number; b: number; sum: number }>();
|
|
312
|
+
set("a", 1);
|
|
313
|
+
set("b", 2);
|
|
314
|
+
comp("sum", () => get("a") + get("b"));
|
|
315
|
+
expect(get("sum")).toBe(3);
|
|
316
|
+
set("a", 10);
|
|
317
|
+
expect(get("sum")).toBe(12);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("computed disposal stops updates", () => {
|
|
321
|
+
expect.assertions(2);
|
|
322
|
+
const { get, set, computed: comp } = createStore<{ x: number; out: number }>();
|
|
323
|
+
set("x", 1);
|
|
324
|
+
const dispose = comp("out", () => get("x") * 2);
|
|
325
|
+
expect(get("out")).toBe(2);
|
|
326
|
+
dispose();
|
|
327
|
+
set("x", 5);
|
|
328
|
+
expect(get("out")).toBe(2);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("dynamic deps in effect", () => {
|
|
332
|
+
expect.assertions(1);
|
|
333
|
+
const { get, set, effect: eff } = createStore<{ cond: boolean; a: number; b: number }>();
|
|
334
|
+
set("cond", true);
|
|
335
|
+
set("a", 1);
|
|
336
|
+
set("b", 2);
|
|
337
|
+
const spy = vi.fn();
|
|
338
|
+
eff(() => {
|
|
339
|
+
spy(get("cond") ? get("a") : get("b"));
|
|
340
|
+
});
|
|
341
|
+
spy.mockClear();
|
|
342
|
+
set("cond", false); // now tracks b, not a
|
|
343
|
+
spy.mockClear();
|
|
344
|
+
set("a", 99); // should NOT trigger
|
|
345
|
+
expect(spy).not.toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// reconcile (keyed-list)
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
describe("reconcile", () => {
|
|
354
|
+
it("mounts items into empty parent", () => {
|
|
355
|
+
expect.assertions(2);
|
|
356
|
+
const parent = document.createElement("div");
|
|
357
|
+
reconcile(parent, [{ id: "a" }, { id: "b" }], {
|
|
358
|
+
key: (i) => i.id,
|
|
359
|
+
mount: (i) => {
|
|
360
|
+
const el = document.createElement("span");
|
|
361
|
+
el.textContent = i.id;
|
|
362
|
+
return el;
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
expect(parent.children.length).toBe(2);
|
|
366
|
+
expect(parent.children[0]!.getAttribute(KEY_ATTR)).toBe("a");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("removes orphans", () => {
|
|
370
|
+
expect.assertions(1);
|
|
371
|
+
const parent = document.createElement("div");
|
|
372
|
+
reconcile(parent, [{ id: "a" }, { id: "b" }], {
|
|
373
|
+
key: (i) => i.id,
|
|
374
|
+
mount: (i) => {
|
|
375
|
+
const el = document.createElement("span");
|
|
376
|
+
el.textContent = i.id;
|
|
377
|
+
return el;
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
reconcile(parent, [{ id: "a" }], {
|
|
381
|
+
key: (i) => i.id,
|
|
382
|
+
mount: (i) => {
|
|
383
|
+
const el = document.createElement("span");
|
|
384
|
+
el.textContent = i.id;
|
|
385
|
+
return el;
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
expect(parent.children.length).toBe(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("calls onRemove for orphans", () => {
|
|
392
|
+
expect.assertions(1);
|
|
393
|
+
const parent = document.createElement("div");
|
|
394
|
+
const removed: string[] = [];
|
|
395
|
+
const spec = {
|
|
396
|
+
key: (i: { id: string }) => i.id,
|
|
397
|
+
mount: (i: { id: string }) => {
|
|
398
|
+
const el = document.createElement("span");
|
|
399
|
+
el.textContent = i.id;
|
|
400
|
+
return el;
|
|
401
|
+
},
|
|
402
|
+
onRemove: (_el: HTMLElement, key: string) => {
|
|
403
|
+
removed.push(key);
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
reconcile(parent, [{ id: "a" }, { id: "b" }], spec);
|
|
407
|
+
reconcile(parent, [{ id: "b" }], spec);
|
|
408
|
+
expect(removed).toEqual(["a"]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("reorders without remounting", () => {
|
|
412
|
+
expect.assertions(2);
|
|
413
|
+
const parent = document.createElement("div");
|
|
414
|
+
const spec = {
|
|
415
|
+
key: (i: { id: string }) => i.id,
|
|
416
|
+
mount: (i: { id: string }) => {
|
|
417
|
+
const el = document.createElement("div");
|
|
418
|
+
el.textContent = i.id;
|
|
419
|
+
return el;
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
reconcile(parent, [{ id: "a" }, { id: "b" }, { id: "c" }], spec);
|
|
423
|
+
const origB = parent.children[1]!;
|
|
424
|
+
reconcile(parent, [{ id: "c" }, { id: "b" }, { id: "a" }], spec);
|
|
425
|
+
expect(parent.children[1]).toBe(origB); // same DOM node
|
|
426
|
+
expect(parent.children[0]!.textContent).toBe("c");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("calls update on existing items", () => {
|
|
430
|
+
expect.assertions(1);
|
|
431
|
+
const parent = document.createElement("div");
|
|
432
|
+
const updated: string[] = [];
|
|
433
|
+
const spec = {
|
|
434
|
+
key: (i: { id: string; v: number }) => i.id,
|
|
435
|
+
mount: (i: { id: string; v: number }) => {
|
|
436
|
+
const el = document.createElement("div");
|
|
437
|
+
el.textContent = String(i.v);
|
|
438
|
+
return el;
|
|
439
|
+
},
|
|
440
|
+
update: (el: HTMLElement, i: { id: string; v: number }) => {
|
|
441
|
+
el.textContent = String(i.v);
|
|
442
|
+
updated.push(i.id);
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
reconcile(parent, [{ id: "a", v: 1 }], spec);
|
|
446
|
+
reconcile(parent, [{ id: "a", v: 2 }], spec);
|
|
447
|
+
expect(updated).toEqual(["a"]);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// patch (tree-diff)
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
describe("patch", () => {
|
|
456
|
+
it("patches text content", () => {
|
|
457
|
+
expect.assertions(1);
|
|
458
|
+
const parent = document.createElement("div");
|
|
459
|
+
parent.appendChild(document.createTextNode("old"));
|
|
460
|
+
patch(parent, "new");
|
|
461
|
+
expect(parent.textContent).toBe("new");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("patches attributes", () => {
|
|
465
|
+
expect.assertions(2);
|
|
466
|
+
const parent = document.createElement("div");
|
|
467
|
+
const child = document.createElement("span");
|
|
468
|
+
child.setAttribute("class", "a");
|
|
469
|
+
parent.appendChild(child);
|
|
470
|
+
const newChild = document.createElement("span");
|
|
471
|
+
newChild.setAttribute("class", "b");
|
|
472
|
+
patch(parent, newChild);
|
|
473
|
+
expect(parent.children[0]!.getAttribute("class")).toBe("b");
|
|
474
|
+
expect(parent.children.length).toBe(1);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("adds and removes children", () => {
|
|
478
|
+
expect.assertions(2);
|
|
479
|
+
const parent = document.createElement("div");
|
|
480
|
+
patch(parent, document.createElement("p"), document.createElement("p"));
|
|
481
|
+
expect(parent.children.length).toBe(2);
|
|
482
|
+
patch(parent, document.createElement("p"));
|
|
483
|
+
expect(parent.children.length).toBe(1);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// computed (derived signal)
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
describe("computed", () => {
|
|
492
|
+
it("returns derived value", () => {
|
|
493
|
+
expect.assertions(1);
|
|
494
|
+
const a = signal(2);
|
|
495
|
+
const b = signal(3);
|
|
496
|
+
const sum = computed(() => a.value + b.value);
|
|
497
|
+
expect(sum.value).toBe(5);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("updates when dependency changes", () => {
|
|
501
|
+
expect.assertions(2);
|
|
502
|
+
const s = signal(1);
|
|
503
|
+
const doubled = computed(() => s.value * 2);
|
|
504
|
+
expect(doubled.value).toBe(2);
|
|
505
|
+
s.value = 5;
|
|
506
|
+
expect(doubled.value).toBe(10);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("lazy evaluation (fn not called until .value read)", () => {
|
|
510
|
+
expect.assertions(2);
|
|
511
|
+
const spy = vi.fn(() => 42);
|
|
512
|
+
const c = computed(spy);
|
|
513
|
+
expect(spy).not.toHaveBeenCalled();
|
|
514
|
+
expect(c.value).toBe(42);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("caches value (fn not called on repeated reads)", () => {
|
|
518
|
+
expect.assertions(2);
|
|
519
|
+
const s = signal(1);
|
|
520
|
+
const spy = vi.fn(() => s.value * 2);
|
|
521
|
+
const c = computed(spy);
|
|
522
|
+
void c.value;
|
|
523
|
+
void c.value;
|
|
524
|
+
void c.value;
|
|
525
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
526
|
+
s.value = 2;
|
|
527
|
+
void c.value;
|
|
528
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("works with effects (effect re-runs when computed changes)", () => {
|
|
532
|
+
expect.assertions(2);
|
|
533
|
+
const s = signal(1);
|
|
534
|
+
const doubled = computed(() => s.value * 2);
|
|
535
|
+
const spy = vi.fn();
|
|
536
|
+
effect(() => {
|
|
537
|
+
spy(doubled.value);
|
|
538
|
+
});
|
|
539
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
540
|
+
s.value = 3;
|
|
541
|
+
expect(spy).toHaveBeenCalledWith(6);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("peek() returns value without tracking", () => {
|
|
545
|
+
expect.assertions(2);
|
|
546
|
+
const s = signal(10);
|
|
547
|
+
const c = computed(() => s.value + 1);
|
|
548
|
+
const spy = vi.fn();
|
|
549
|
+
effect(() => {
|
|
550
|
+
spy(c.peek());
|
|
551
|
+
});
|
|
552
|
+
expect(spy).toHaveBeenCalledWith(11);
|
|
553
|
+
spy.mockClear();
|
|
554
|
+
s.value = 20;
|
|
555
|
+
// effect should NOT re-run because peek() doesn't track
|
|
556
|
+
expect(spy).not.toHaveBeenCalled();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("chains computed signals", () => {
|
|
560
|
+
expect.assertions(1);
|
|
561
|
+
const a = signal(1);
|
|
562
|
+
const b = computed(() => a.value * 2);
|
|
563
|
+
const c = computed(() => b.value + 10);
|
|
564
|
+
expect(c.value).toBe(12);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// patchAttrs handler reconciliation (trackHandler)
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
describe("patchAttrs handler reconciliation", () => {
|
|
573
|
+
it("removes stale handlers on patch", () => {
|
|
574
|
+
expect.assertions(1);
|
|
575
|
+
const parent = document.createElement("div");
|
|
576
|
+
const oldEl = document.createElement("button");
|
|
577
|
+
const handler = vi.fn();
|
|
578
|
+
(oldEl as unknown as Record<string, unknown>)["onclick"] = handler;
|
|
579
|
+
trackHandler(oldEl, "onclick");
|
|
580
|
+
parent.appendChild(oldEl);
|
|
581
|
+
|
|
582
|
+
const newEl = document.createElement("button");
|
|
583
|
+
// no handler registered on newEl
|
|
584
|
+
patch(parent, newEl);
|
|
585
|
+
|
|
586
|
+
expect((parent.children[0] as unknown as Record<string, unknown>)["onclick"]).toBeNull();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("copies new handlers on patch", () => {
|
|
590
|
+
expect.assertions(1);
|
|
591
|
+
const parent = document.createElement("div");
|
|
592
|
+
const oldEl = document.createElement("button");
|
|
593
|
+
parent.appendChild(oldEl);
|
|
594
|
+
|
|
595
|
+
const newEl = document.createElement("button");
|
|
596
|
+
const handler = vi.fn();
|
|
597
|
+
(newEl as unknown as Record<string, unknown>)["onmouseover"] = handler;
|
|
598
|
+
trackHandler(newEl, "onmouseover");
|
|
599
|
+
|
|
600
|
+
patch(parent, newEl);
|
|
601
|
+
|
|
602
|
+
expect((parent.children[0] as unknown as Record<string, unknown>)["onmouseover"]).toBe(handler);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("updates handler reference on patch", () => {
|
|
606
|
+
expect.assertions(2);
|
|
607
|
+
const parent = document.createElement("div");
|
|
608
|
+
const oldEl = document.createElement("button");
|
|
609
|
+
const oldHandler = vi.fn();
|
|
610
|
+
(oldEl as unknown as Record<string, unknown>)["onclick"] = oldHandler;
|
|
611
|
+
trackHandler(oldEl, "onclick");
|
|
612
|
+
parent.appendChild(oldEl);
|
|
613
|
+
|
|
614
|
+
const newEl = document.createElement("button");
|
|
615
|
+
const newHandler = vi.fn();
|
|
616
|
+
(newEl as unknown as Record<string, unknown>)["onclick"] = newHandler;
|
|
617
|
+
trackHandler(newEl, "onclick");
|
|
618
|
+
|
|
619
|
+
patch(parent, newEl);
|
|
620
|
+
|
|
621
|
+
const result = (parent.children[0] as unknown as Record<string, unknown>)["onclick"];
|
|
622
|
+
expect(result).toBe(newHandler);
|
|
623
|
+
expect(result).not.toBe(oldHandler);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// reconcile: extended tests (ported from vibekit/static-src/reconcile.test.ts)
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
describe("reconcile: extended", () => {
|
|
632
|
+
interface Item {
|
|
633
|
+
id: string;
|
|
634
|
+
label: string;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function mount(item: Item): HTMLElement {
|
|
638
|
+
const li = document.createElement("li");
|
|
639
|
+
li.textContent = item.label;
|
|
640
|
+
return li;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function update(el: HTMLElement, item: Item): void {
|
|
644
|
+
el.textContent = item.label;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const spec: ReconcileSpec<Item> = { key: (i) => i.id, mount, update };
|
|
648
|
+
|
|
649
|
+
function rendered(parent: ParentNode): string[] {
|
|
650
|
+
const out: string[] = [];
|
|
651
|
+
for (let n = parent.firstChild; n !== null; n = n.nextSibling) {
|
|
652
|
+
if (n.nodeType === 1) {
|
|
653
|
+
out.push((n as HTMLElement).textContent ?? "");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function snapshotRefs(parent: ParentNode): HTMLElement[] {
|
|
660
|
+
const out: HTMLElement[] = [];
|
|
661
|
+
for (let n = parent.firstChild; n !== null; n = n.nextSibling) {
|
|
662
|
+
if (n.nodeType === 1) {
|
|
663
|
+
out.push(n as HTMLElement);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function makeUL(): HTMLUListElement {
|
|
670
|
+
return document.createElement("ul");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
describe("empty cases", () => {
|
|
674
|
+
it("empty parent + empty items → no-op", () => {
|
|
675
|
+
const ul = makeUL();
|
|
676
|
+
reconcile(ul, [], spec);
|
|
677
|
+
expect(ul.children.length).toBe(0);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("populated parent + empty items → all removed", () => {
|
|
681
|
+
const ul = makeUL();
|
|
682
|
+
reconcile(
|
|
683
|
+
ul,
|
|
684
|
+
[
|
|
685
|
+
{ id: "a", label: "A" },
|
|
686
|
+
{ id: "b", label: "B" },
|
|
687
|
+
],
|
|
688
|
+
spec,
|
|
689
|
+
);
|
|
690
|
+
expect(rendered(ul)).toEqual(["A", "B"]);
|
|
691
|
+
reconcile(ul, [], spec);
|
|
692
|
+
expect(ul.children.length).toBe(0);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("empty parent + items → all mounted in order", () => {
|
|
696
|
+
const ul = makeUL();
|
|
697
|
+
reconcile(
|
|
698
|
+
ul,
|
|
699
|
+
[
|
|
700
|
+
{ id: "a", label: "A" },
|
|
701
|
+
{ id: "b", label: "B" },
|
|
702
|
+
{ id: "c", label: "C" },
|
|
703
|
+
],
|
|
704
|
+
spec,
|
|
705
|
+
);
|
|
706
|
+
expect(rendered(ul)).toEqual(["A", "B", "C"]);
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe("identity preservation", () => {
|
|
711
|
+
it("no-op call: every element kept by reference", () => {
|
|
712
|
+
const ul = makeUL();
|
|
713
|
+
const items: Item[] = [
|
|
714
|
+
{ id: "a", label: "A" },
|
|
715
|
+
{ id: "b", label: "B" },
|
|
716
|
+
{ id: "c", label: "C" },
|
|
717
|
+
];
|
|
718
|
+
reconcile(ul, items, spec);
|
|
719
|
+
const before = snapshotRefs(ul);
|
|
720
|
+
reconcile(ul, items, spec);
|
|
721
|
+
const after = snapshotRefs(ul);
|
|
722
|
+
expect(after).toEqual(before);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("update mutates in place; identity preserved", () => {
|
|
726
|
+
const ul = makeUL();
|
|
727
|
+
reconcile(
|
|
728
|
+
ul,
|
|
729
|
+
[
|
|
730
|
+
{ id: "a", label: "A" },
|
|
731
|
+
{ id: "b", label: "B" },
|
|
732
|
+
],
|
|
733
|
+
spec,
|
|
734
|
+
);
|
|
735
|
+
const [aBefore, bBefore] = snapshotRefs(ul);
|
|
736
|
+
reconcile(
|
|
737
|
+
ul,
|
|
738
|
+
[
|
|
739
|
+
{ id: "a", label: "A!" },
|
|
740
|
+
{ id: "b", label: "B!" },
|
|
741
|
+
],
|
|
742
|
+
spec,
|
|
743
|
+
);
|
|
744
|
+
const [aAfter, bAfter] = snapshotRefs(ul);
|
|
745
|
+
expect(aAfter).toBe(aBefore);
|
|
746
|
+
expect(bAfter).toBe(bBefore);
|
|
747
|
+
expect(rendered(ul)).toEqual(["A!", "B!"]);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("reorder: identity preserved across moves", () => {
|
|
751
|
+
const ul = makeUL();
|
|
752
|
+
reconcile(
|
|
753
|
+
ul,
|
|
754
|
+
[
|
|
755
|
+
{ id: "a", label: "A" },
|
|
756
|
+
{ id: "b", label: "B" },
|
|
757
|
+
{ id: "c", label: "C" },
|
|
758
|
+
],
|
|
759
|
+
spec,
|
|
760
|
+
);
|
|
761
|
+
const refs = new Map<string, HTMLElement>();
|
|
762
|
+
for (const el of snapshotRefs(ul)) {
|
|
763
|
+
refs.set(el.getAttribute(KEY_ATTR) ?? "", el);
|
|
764
|
+
}
|
|
765
|
+
reconcile(
|
|
766
|
+
ul,
|
|
767
|
+
[
|
|
768
|
+
{ id: "c", label: "C" },
|
|
769
|
+
{ id: "a", label: "A" },
|
|
770
|
+
{ id: "b", label: "B" },
|
|
771
|
+
],
|
|
772
|
+
spec,
|
|
773
|
+
);
|
|
774
|
+
expect(rendered(ul)).toEqual(["C", "A", "B"]);
|
|
775
|
+
for (const el of snapshotRefs(ul)) {
|
|
776
|
+
const k = el.getAttribute(KEY_ATTR) ?? "";
|
|
777
|
+
expect(el).toBe(refs.get(k));
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
describe("insert / remove / mixed", () => {
|
|
783
|
+
it("appends new items at end", () => {
|
|
784
|
+
const ul = makeUL();
|
|
785
|
+
reconcile(ul, [{ id: "a", label: "A" }], spec);
|
|
786
|
+
reconcile(
|
|
787
|
+
ul,
|
|
788
|
+
[
|
|
789
|
+
{ id: "a", label: "A" },
|
|
790
|
+
{ id: "b", label: "B" },
|
|
791
|
+
{ id: "c", label: "C" },
|
|
792
|
+
],
|
|
793
|
+
spec,
|
|
794
|
+
);
|
|
795
|
+
expect(rendered(ul)).toEqual(["A", "B", "C"]);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("prepends new items at front", () => {
|
|
799
|
+
const ul = makeUL();
|
|
800
|
+
reconcile(ul, [{ id: "z", label: "Z" }], spec);
|
|
801
|
+
reconcile(
|
|
802
|
+
ul,
|
|
803
|
+
[
|
|
804
|
+
{ id: "a", label: "A" },
|
|
805
|
+
{ id: "b", label: "B" },
|
|
806
|
+
{ id: "z", label: "Z" },
|
|
807
|
+
],
|
|
808
|
+
spec,
|
|
809
|
+
);
|
|
810
|
+
expect(rendered(ul)).toEqual(["A", "B", "Z"]);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("inserts in the middle", () => {
|
|
814
|
+
const ul = makeUL();
|
|
815
|
+
reconcile(
|
|
816
|
+
ul,
|
|
817
|
+
[
|
|
818
|
+
{ id: "a", label: "A" },
|
|
819
|
+
{ id: "c", label: "C" },
|
|
820
|
+
],
|
|
821
|
+
spec,
|
|
822
|
+
);
|
|
823
|
+
reconcile(
|
|
824
|
+
ul,
|
|
825
|
+
[
|
|
826
|
+
{ id: "a", label: "A" },
|
|
827
|
+
{ id: "b", label: "B" },
|
|
828
|
+
{ id: "c", label: "C" },
|
|
829
|
+
],
|
|
830
|
+
spec,
|
|
831
|
+
);
|
|
832
|
+
expect(rendered(ul)).toEqual(["A", "B", "C"]);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("removes from the middle", () => {
|
|
836
|
+
const ul = makeUL();
|
|
837
|
+
reconcile(
|
|
838
|
+
ul,
|
|
839
|
+
[
|
|
840
|
+
{ id: "a", label: "A" },
|
|
841
|
+
{ id: "b", label: "B" },
|
|
842
|
+
{ id: "c", label: "C" },
|
|
843
|
+
],
|
|
844
|
+
spec,
|
|
845
|
+
);
|
|
846
|
+
reconcile(
|
|
847
|
+
ul,
|
|
848
|
+
[
|
|
849
|
+
{ id: "a", label: "A" },
|
|
850
|
+
{ id: "c", label: "C" },
|
|
851
|
+
],
|
|
852
|
+
spec,
|
|
853
|
+
);
|
|
854
|
+
expect(rendered(ul)).toEqual(["A", "C"]);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("reverse order", () => {
|
|
858
|
+
const ul = makeUL();
|
|
859
|
+
reconcile(
|
|
860
|
+
ul,
|
|
861
|
+
[
|
|
862
|
+
{ id: "a", label: "A" },
|
|
863
|
+
{ id: "b", label: "B" },
|
|
864
|
+
{ id: "c", label: "C" },
|
|
865
|
+
],
|
|
866
|
+
spec,
|
|
867
|
+
);
|
|
868
|
+
reconcile(
|
|
869
|
+
ul,
|
|
870
|
+
[
|
|
871
|
+
{ id: "c", label: "C" },
|
|
872
|
+
{ id: "b", label: "B" },
|
|
873
|
+
{ id: "a", label: "A" },
|
|
874
|
+
],
|
|
875
|
+
spec,
|
|
876
|
+
);
|
|
877
|
+
expect(rendered(ul)).toEqual(["C", "B", "A"]);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("mixed: insert + remove + update + reorder", () => {
|
|
881
|
+
const ul = makeUL();
|
|
882
|
+
reconcile(
|
|
883
|
+
ul,
|
|
884
|
+
[
|
|
885
|
+
{ id: "a", label: "A" },
|
|
886
|
+
{ id: "b", label: "B" },
|
|
887
|
+
{ id: "c", label: "C" },
|
|
888
|
+
],
|
|
889
|
+
spec,
|
|
890
|
+
);
|
|
891
|
+
reconcile(
|
|
892
|
+
ul,
|
|
893
|
+
[
|
|
894
|
+
{ id: "d", label: "D" },
|
|
895
|
+
{ id: "b", label: "B!" },
|
|
896
|
+
{ id: "a", label: "A" },
|
|
897
|
+
],
|
|
898
|
+
spec,
|
|
899
|
+
);
|
|
900
|
+
expect(rendered(ul)).toEqual(["D", "B!", "A"]);
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
describe("update optionality", () => {
|
|
905
|
+
it("omitting update leaves existing rows unchanged", () => {
|
|
906
|
+
const ul = makeUL();
|
|
907
|
+
const noUpdateSpec: ReconcileSpec<Item> = { key: (i) => i.id, mount };
|
|
908
|
+
reconcile(ul, [{ id: "a", label: "A" }], noUpdateSpec);
|
|
909
|
+
reconcile(ul, [{ id: "a", label: "A — but ignored" }], noUpdateSpec);
|
|
910
|
+
expect(rendered(ul)).toEqual(["A"]);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it("update is called only on existing items, not on freshly-mounted ones", () => {
|
|
914
|
+
const ul = makeUL();
|
|
915
|
+
let updateCalls = 0;
|
|
916
|
+
const countingSpec: ReconcileSpec<Item> = {
|
|
917
|
+
key: (i) => i.id,
|
|
918
|
+
mount,
|
|
919
|
+
update: (el, item) => {
|
|
920
|
+
updateCalls++;
|
|
921
|
+
update(el, item);
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
reconcile(ul, [{ id: "a", label: "A" }], countingSpec);
|
|
925
|
+
expect(updateCalls).toBe(0);
|
|
926
|
+
reconcile(
|
|
927
|
+
ul,
|
|
928
|
+
[
|
|
929
|
+
{ id: "a", label: "A" },
|
|
930
|
+
{ id: "b", label: "B" },
|
|
931
|
+
],
|
|
932
|
+
countingSpec,
|
|
933
|
+
);
|
|
934
|
+
expect(updateCalls).toBe(1);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
describe("non-keyed siblings preserved", () => {
|
|
939
|
+
it("ignores children without data-reconcile-key", () => {
|
|
940
|
+
const ul = makeUL();
|
|
941
|
+
const header = document.createElement("li");
|
|
942
|
+
header.className = "header";
|
|
943
|
+
header.textContent = "HEADER";
|
|
944
|
+
ul.appendChild(header);
|
|
945
|
+
|
|
946
|
+
reconcile(
|
|
947
|
+
ul,
|
|
948
|
+
[
|
|
949
|
+
{ id: "a", label: "A" },
|
|
950
|
+
{ id: "b", label: "B" },
|
|
951
|
+
],
|
|
952
|
+
spec,
|
|
953
|
+
);
|
|
954
|
+
expect(rendered(ul)).toEqual(["HEADER", "A", "B"]);
|
|
955
|
+
expect(ul.querySelector(".header")).toBe(header);
|
|
956
|
+
|
|
957
|
+
reconcile(ul, [{ id: "b", label: "B" }], spec);
|
|
958
|
+
expect(rendered(ul)).toEqual(["HEADER", "B"]);
|
|
959
|
+
expect(ul.querySelector(".header")).toBe(header);
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
describe("signal integration", () => {
|
|
964
|
+
it("effect + reconcile patches DOM on every signal mutation", () => {
|
|
965
|
+
const ul = makeUL();
|
|
966
|
+
const items = signal<readonly Item[]>([]);
|
|
967
|
+
const stop = effect(() => {
|
|
968
|
+
reconcile(ul, items.value, spec);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
items.value = [{ id: "a", label: "A" }];
|
|
972
|
+
expect(rendered(ul)).toEqual(["A"]);
|
|
973
|
+
|
|
974
|
+
items.value = [
|
|
975
|
+
{ id: "a", label: "A" },
|
|
976
|
+
{ id: "b", label: "B" },
|
|
977
|
+
];
|
|
978
|
+
expect(rendered(ul)).toEqual(["A", "B"]);
|
|
979
|
+
|
|
980
|
+
const aBefore = ul.children[0];
|
|
981
|
+
items.value = [
|
|
982
|
+
{ id: "b", label: "B" },
|
|
983
|
+
{ id: "a", label: "A!" },
|
|
984
|
+
];
|
|
985
|
+
expect(rendered(ul)).toEqual(["B", "A!"]);
|
|
986
|
+
expect(ul.children[1]).toBe(aBefore);
|
|
987
|
+
|
|
988
|
+
items.value = [];
|
|
989
|
+
expect(ul.children.length).toBe(0);
|
|
990
|
+
|
|
991
|
+
stop();
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
describe("idempotency", () => {
|
|
996
|
+
it("calling twice with the same input is a no-op the second time", () => {
|
|
997
|
+
const ul = makeUL();
|
|
998
|
+
const items: Item[] = [
|
|
999
|
+
{ id: "a", label: "A" },
|
|
1000
|
+
{ id: "b", label: "B" },
|
|
1001
|
+
{ id: "c", label: "C" },
|
|
1002
|
+
];
|
|
1003
|
+
reconcile(ul, items, spec);
|
|
1004
|
+
const before = snapshotRefs(ul);
|
|
1005
|
+
reconcile(ul, items, spec);
|
|
1006
|
+
const after = snapshotRefs(ul);
|
|
1007
|
+
expect(after).toEqual(before);
|
|
1008
|
+
expect(rendered(ul)).toEqual(["A", "B", "C"]);
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
describe("data-reconcile-key tagging", () => {
|
|
1013
|
+
it("mounted elements receive the key as data-reconcile-key", () => {
|
|
1014
|
+
const ul = makeUL();
|
|
1015
|
+
reconcile(ul, [{ id: "alpha", label: "A" }], spec);
|
|
1016
|
+
expect(ul.children[0]!.getAttribute(KEY_ATTR)).toBe("alpha");
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("subsequent mounts with the same key reuse the existing element", () => {
|
|
1020
|
+
const ul = makeUL();
|
|
1021
|
+
reconcile(ul, [{ id: "x", label: "X" }], spec);
|
|
1022
|
+
const first = ul.children[0];
|
|
1023
|
+
reconcile(ul, [{ id: "x", label: "X2" }], spec);
|
|
1024
|
+
expect(ul.children[0]).toBe(first);
|
|
1025
|
+
expect(first?.textContent).toBe("X2");
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
describe("onRemove hook", () => {
|
|
1030
|
+
it("fires for each orphaned element with element + key", () => {
|
|
1031
|
+
const ul = makeUL();
|
|
1032
|
+
const removed: string[] = [];
|
|
1033
|
+
const teardownSpec: ReconcileSpec<Item> = {
|
|
1034
|
+
key: (i) => i.id,
|
|
1035
|
+
mount,
|
|
1036
|
+
update,
|
|
1037
|
+
onRemove: (_el, key) => {
|
|
1038
|
+
removed.push(key);
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
reconcile(
|
|
1042
|
+
ul,
|
|
1043
|
+
[
|
|
1044
|
+
{ id: "a", label: "A" },
|
|
1045
|
+
{ id: "b", label: "B" },
|
|
1046
|
+
{ id: "c", label: "C" },
|
|
1047
|
+
],
|
|
1048
|
+
teardownSpec,
|
|
1049
|
+
);
|
|
1050
|
+
reconcile(ul, [{ id: "b", label: "B" }], teardownSpec);
|
|
1051
|
+
expect(removed.sort()).toEqual(["a", "c"]);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("does not fire when an element is kept (just moved or updated)", () => {
|
|
1055
|
+
const ul = makeUL();
|
|
1056
|
+
const removed: string[] = [];
|
|
1057
|
+
const teardownSpec: ReconcileSpec<Item> = {
|
|
1058
|
+
key: (i) => i.id,
|
|
1059
|
+
mount,
|
|
1060
|
+
update,
|
|
1061
|
+
onRemove: (_, key) => {
|
|
1062
|
+
removed.push(key);
|
|
1063
|
+
},
|
|
1064
|
+
};
|
|
1065
|
+
reconcile(
|
|
1066
|
+
ul,
|
|
1067
|
+
[
|
|
1068
|
+
{ id: "a", label: "A" },
|
|
1069
|
+
{ id: "b", label: "B" },
|
|
1070
|
+
],
|
|
1071
|
+
teardownSpec,
|
|
1072
|
+
);
|
|
1073
|
+
reconcile(
|
|
1074
|
+
ul,
|
|
1075
|
+
[
|
|
1076
|
+
{ id: "b", label: "B!" },
|
|
1077
|
+
{ id: "a", label: "A!" },
|
|
1078
|
+
],
|
|
1079
|
+
teardownSpec,
|
|
1080
|
+
);
|
|
1081
|
+
expect(removed).toEqual([]);
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
describe("nested usage", () => {
|
|
1086
|
+
it("inner reconcile inside mount/update produces correctly-keyed sublists", () => {
|
|
1087
|
+
interface Section {
|
|
1088
|
+
id: string;
|
|
1089
|
+
rows: Item[];
|
|
1090
|
+
}
|
|
1091
|
+
const root = document.createElement("div");
|
|
1092
|
+
|
|
1093
|
+
const renderRow: ReconcileSpec<Item> = {
|
|
1094
|
+
key: (i) => i.id,
|
|
1095
|
+
mount: (i) => {
|
|
1096
|
+
const li = document.createElement("li");
|
|
1097
|
+
li.textContent = i.label;
|
|
1098
|
+
return li;
|
|
1099
|
+
},
|
|
1100
|
+
update: (el, i) => {
|
|
1101
|
+
el.textContent = i.label;
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
const renderSection: ReconcileSpec<Section> = {
|
|
1106
|
+
key: (s) => s.id,
|
|
1107
|
+
mount: (s) => {
|
|
1108
|
+
const sec = document.createElement("section");
|
|
1109
|
+
const ul = document.createElement("ul");
|
|
1110
|
+
sec.appendChild(ul);
|
|
1111
|
+
reconcile(ul, s.rows, renderRow);
|
|
1112
|
+
return sec;
|
|
1113
|
+
},
|
|
1114
|
+
update: (sec, s) => {
|
|
1115
|
+
const ul = sec.querySelector("ul");
|
|
1116
|
+
if (ul !== null) {
|
|
1117
|
+
reconcile(ul, s.rows, renderRow);
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
reconcile(
|
|
1123
|
+
root,
|
|
1124
|
+
[
|
|
1125
|
+
{
|
|
1126
|
+
id: "s1",
|
|
1127
|
+
rows: [
|
|
1128
|
+
{ id: "a", label: "A" },
|
|
1129
|
+
{ id: "b", label: "B" },
|
|
1130
|
+
],
|
|
1131
|
+
},
|
|
1132
|
+
{ id: "s2", rows: [{ id: "c", label: "C" }] },
|
|
1133
|
+
],
|
|
1134
|
+
renderSection,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const sec1 = root.children[0]!;
|
|
1138
|
+
expect(rendered(sec1.querySelector("ul")!)).toEqual(["A", "B"]);
|
|
1139
|
+
expect(rendered(root.children[1]!.querySelector("ul")!)).toEqual(["C"]);
|
|
1140
|
+
|
|
1141
|
+
const sec1Before = sec1;
|
|
1142
|
+
reconcile(
|
|
1143
|
+
root,
|
|
1144
|
+
[
|
|
1145
|
+
{ id: "s1", rows: [{ id: "a", label: "A!" }] },
|
|
1146
|
+
{
|
|
1147
|
+
id: "s2",
|
|
1148
|
+
rows: [
|
|
1149
|
+
{ id: "c", label: "C" },
|
|
1150
|
+
{ id: "d", label: "D" },
|
|
1151
|
+
],
|
|
1152
|
+
},
|
|
1153
|
+
],
|
|
1154
|
+
renderSection,
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
expect(root.children[0]).toBe(sec1Before);
|
|
1158
|
+
expect(rendered(root.children[0]!.querySelector("ul")!)).toEqual(["A!"]);
|
|
1159
|
+
expect(rendered(root.children[1]!.querySelector("ul")!)).toEqual(["C", "D"]);
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// ---------------------------------------------------------------------------
|
|
1165
|
+
// Computed: diamond problem / glitch-freedom (GZ-1)
|
|
1166
|
+
// ---------------------------------------------------------------------------
|
|
1167
|
+
|
|
1168
|
+
describe("computed: diamond / glitch-freedom", () => {
|
|
1169
|
+
it("does not notify downstream when computed value is unchanged", () => {
|
|
1170
|
+
const a = signal(1);
|
|
1171
|
+
const isPositive = computed(() => a.value > 0);
|
|
1172
|
+
const spy = vi.fn();
|
|
1173
|
+
effect(() => {
|
|
1174
|
+
spy(isPositive.value);
|
|
1175
|
+
});
|
|
1176
|
+
spy.mockClear();
|
|
1177
|
+
a.value = 2; // still > 0, isPositive still true
|
|
1178
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it("diamond graph: effect runs once per batch", () => {
|
|
1182
|
+
const src = signal(1);
|
|
1183
|
+
const left = computed(() => src.value * 2);
|
|
1184
|
+
const right = computed(() => src.value * 3);
|
|
1185
|
+
const combined = computed(() => left.value + right.value);
|
|
1186
|
+
const spy = vi.fn();
|
|
1187
|
+
effect(() => {
|
|
1188
|
+
spy(combined.value);
|
|
1189
|
+
});
|
|
1190
|
+
expect(spy).toHaveBeenCalledWith(5); // 2+3
|
|
1191
|
+
spy.mockClear();
|
|
1192
|
+
src.value = 2;
|
|
1193
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1194
|
+
expect(spy).toHaveBeenCalledWith(10); // 4+6
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
it("diamond with batch: single effect execution", () => {
|
|
1198
|
+
const a = signal(0);
|
|
1199
|
+
const b = signal(0);
|
|
1200
|
+
const sum = computed(() => a.value + b.value);
|
|
1201
|
+
const spy = vi.fn();
|
|
1202
|
+
effect(() => {
|
|
1203
|
+
spy(sum.value);
|
|
1204
|
+
});
|
|
1205
|
+
spy.mockClear();
|
|
1206
|
+
batch(() => {
|
|
1207
|
+
a.value = 1;
|
|
1208
|
+
b.value = 1;
|
|
1209
|
+
});
|
|
1210
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1211
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
// ---------------------------------------------------------------------------
|
|
1216
|
+
// Computed: cycle detection (GZ-4)
|
|
1217
|
+
// ---------------------------------------------------------------------------
|
|
1218
|
+
|
|
1219
|
+
describe("computed: cycle detection", () => {
|
|
1220
|
+
it("throws on self-referencing computed", () => {
|
|
1221
|
+
const c: ReadonlySignal<number> = computed(() => c.value + 1);
|
|
1222
|
+
expect(() => c.value).toThrow("Cycle detected");
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it("throws on indirect cycle", () => {
|
|
1226
|
+
const a: ReadonlySignal<number> = computed(() => b.value + 1);
|
|
1227
|
+
const b: ReadonlySignal<number> = computed(() => a.value + 1);
|
|
1228
|
+
expect(() => a.value).toThrow("Cycle detected");
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
// ---------------------------------------------------------------------------
|
|
1233
|
+
// Computed: error caching (Gap #12)
|
|
1234
|
+
// ---------------------------------------------------------------------------
|
|
1235
|
+
|
|
1236
|
+
describe("computed: error caching", () => {
|
|
1237
|
+
it("caches thrown error and rethrows on subsequent reads", () => {
|
|
1238
|
+
let count = 0;
|
|
1239
|
+
const c = computed(() => {
|
|
1240
|
+
count++;
|
|
1241
|
+
throw new Error("fail");
|
|
1242
|
+
});
|
|
1243
|
+
expect(() => c.value).toThrow("fail");
|
|
1244
|
+
expect(() => c.value).toThrow("fail");
|
|
1245
|
+
expect(count).toBe(1); // fn only called once
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("recovers when deps change and fn succeeds", () => {
|
|
1249
|
+
const s = signal(0);
|
|
1250
|
+
const c = computed(() => {
|
|
1251
|
+
if (s.value === 0) {
|
|
1252
|
+
throw new Error("zero");
|
|
1253
|
+
}
|
|
1254
|
+
return s.value * 2;
|
|
1255
|
+
});
|
|
1256
|
+
expect(() => c.value).toThrow("zero");
|
|
1257
|
+
s.value = 5;
|
|
1258
|
+
expect(c.value).toBe(10);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("error in computed propagates to effect via effectErrorHandler", () => {
|
|
1262
|
+
const s = signal(0);
|
|
1263
|
+
const c = computed(() => {
|
|
1264
|
+
if (s.value === 0) {
|
|
1265
|
+
throw new Error("boom");
|
|
1266
|
+
}
|
|
1267
|
+
return s.value;
|
|
1268
|
+
});
|
|
1269
|
+
const errors: unknown[] = [];
|
|
1270
|
+
const prev = setEffectErrorHandler((e) => {
|
|
1271
|
+
errors.push(e);
|
|
1272
|
+
});
|
|
1273
|
+
effect(() => {
|
|
1274
|
+
void c.value;
|
|
1275
|
+
});
|
|
1276
|
+
expect(errors.length).toBe(1);
|
|
1277
|
+
expect((errors[0] as Error).message).toBe("boom");
|
|
1278
|
+
setEffectErrorHandler(prev);
|
|
1279
|
+
});
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
// ---------------------------------------------------------------------------
|
|
1283
|
+
// Computed: setter throws (Gap #7)
|
|
1284
|
+
// ---------------------------------------------------------------------------
|
|
1285
|
+
|
|
1286
|
+
describe("computed: setter throws", () => {
|
|
1287
|
+
it("throws when setting .value on computed", () => {
|
|
1288
|
+
const c = computed(() => 42);
|
|
1289
|
+
expect(() => {
|
|
1290
|
+
(c as { value: number }).value = 99;
|
|
1291
|
+
}).toThrow("Cannot set a computed signal");
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// ---------------------------------------------------------------------------
|
|
1296
|
+
// untracked (Gap #1)
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
describe("untracked", () => {
|
|
1300
|
+
it("reads signal without subscribing", () => {
|
|
1301
|
+
const s = signal(1);
|
|
1302
|
+
const spy = vi.fn();
|
|
1303
|
+
effect(() => {
|
|
1304
|
+
spy(untracked(() => s.value));
|
|
1305
|
+
});
|
|
1306
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
1307
|
+
spy.mockClear();
|
|
1308
|
+
s.value = 2;
|
|
1309
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it("can be nested with tracked reads", () => {
|
|
1313
|
+
const a = signal(1);
|
|
1314
|
+
const b = signal(2);
|
|
1315
|
+
const spy = vi.fn();
|
|
1316
|
+
effect(() => {
|
|
1317
|
+
const av = a.value; // tracked
|
|
1318
|
+
const bv = untracked(() => b.value); // not tracked
|
|
1319
|
+
spy(av + bv);
|
|
1320
|
+
});
|
|
1321
|
+
spy.mockClear();
|
|
1322
|
+
b.value = 99;
|
|
1323
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1324
|
+
a.value = 10;
|
|
1325
|
+
expect(spy).toHaveBeenCalledWith(109); // 10 + 99
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// ---------------------------------------------------------------------------
|
|
1330
|
+
// subscribe utility (Gap #6)
|
|
1331
|
+
// ---------------------------------------------------------------------------
|
|
1332
|
+
|
|
1333
|
+
describe("subscribe", () => {
|
|
1334
|
+
it("calls cb immediately with current value", () => {
|
|
1335
|
+
const s = signal(42);
|
|
1336
|
+
const spy = vi.fn();
|
|
1337
|
+
subscribe(s, spy);
|
|
1338
|
+
expect(spy).toHaveBeenCalledWith(42);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it("calls cb on subsequent changes", () => {
|
|
1342
|
+
const s = signal(0);
|
|
1343
|
+
const spy = vi.fn();
|
|
1344
|
+
subscribe(s, spy);
|
|
1345
|
+
spy.mockClear();
|
|
1346
|
+
s.value = 1;
|
|
1347
|
+
s.value = 2;
|
|
1348
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
1349
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it("returns dispose function", () => {
|
|
1353
|
+
const s = signal(0);
|
|
1354
|
+
const spy = vi.fn();
|
|
1355
|
+
const dispose = subscribe(s, spy);
|
|
1356
|
+
spy.mockClear();
|
|
1357
|
+
dispose();
|
|
1358
|
+
s.value = 99;
|
|
1359
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
it("works with computed signals", () => {
|
|
1363
|
+
const s = signal(3);
|
|
1364
|
+
const c = computed(() => s.value * 2);
|
|
1365
|
+
const spy = vi.fn();
|
|
1366
|
+
subscribe(c, spy);
|
|
1367
|
+
expect(spy).toHaveBeenCalledWith(6);
|
|
1368
|
+
spy.mockClear();
|
|
1369
|
+
s.value = 5;
|
|
1370
|
+
expect(spy).toHaveBeenCalledWith(10);
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
// ---------------------------------------------------------------------------
|
|
1375
|
+
// isSignal / isComputed (Gap #15)
|
|
1376
|
+
// ---------------------------------------------------------------------------
|
|
1377
|
+
|
|
1378
|
+
describe("isSignal / isComputed", () => {
|
|
1379
|
+
it("isSignal returns true for signals", () => {
|
|
1380
|
+
expect(isSignal(signal(1))).toBe(true);
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
it("isSignal returns false for computed", () => {
|
|
1384
|
+
expect(isSignal(computed(() => 1))).toBe(false);
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it("isSignal returns false for plain objects", () => {
|
|
1388
|
+
expect(isSignal({ value: 1 })).toBe(false);
|
|
1389
|
+
expect(isSignal(null)).toBe(false);
|
|
1390
|
+
expect(isSignal(42)).toBe(false);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it("isComputed returns true for computed", () => {
|
|
1394
|
+
expect(isComputed(computed(() => 1))).toBe(true);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
it("isComputed returns false for signals", () => {
|
|
1398
|
+
expect(isComputed(signal(1))).toBe(false);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("isComputed returns false for plain objects", () => {
|
|
1402
|
+
expect(isComputed({ value: 1 })).toBe(false);
|
|
1403
|
+
expect(isComputed(null)).toBe(false);
|
|
1404
|
+
});
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
// ---------------------------------------------------------------------------
|
|
1408
|
+
// Store: Object.is equality (GZ-5)
|
|
1409
|
+
// ---------------------------------------------------------------------------
|
|
1410
|
+
|
|
1411
|
+
describe("store: Object.is equality", () => {
|
|
1412
|
+
it("NaN does not trigger spurious notifications", () => {
|
|
1413
|
+
const { set, subscribe: sub } = createStore<{ x: number }>();
|
|
1414
|
+
set("x", NaN);
|
|
1415
|
+
const spy = vi.fn();
|
|
1416
|
+
sub("x", spy);
|
|
1417
|
+
set("x", NaN);
|
|
1418
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
// ---------------------------------------------------------------------------
|
|
1423
|
+
// Custom equality comparator (signal + computed)
|
|
1424
|
+
// ---------------------------------------------------------------------------
|
|
1425
|
+
|
|
1426
|
+
describe("custom equality comparator", () => {
|
|
1427
|
+
it("signal with custom equals suppresses notification", () => {
|
|
1428
|
+
const s = signal({ x: 1, y: 2 }, { equals: (a, b) => a.x === b.x });
|
|
1429
|
+
const spy = vi.fn();
|
|
1430
|
+
effect(() => {
|
|
1431
|
+
spy(s.value);
|
|
1432
|
+
});
|
|
1433
|
+
spy.mockClear();
|
|
1434
|
+
s.value = { x: 1, y: 99 }; // same x, different y — should NOT notify
|
|
1435
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
it("signal with custom equals allows notification when not equal", () => {
|
|
1439
|
+
const s = signal({ x: 1 }, { equals: (a, b) => a.x === b.x });
|
|
1440
|
+
const spy = vi.fn();
|
|
1441
|
+
effect(() => {
|
|
1442
|
+
spy(s.value.x);
|
|
1443
|
+
});
|
|
1444
|
+
spy.mockClear();
|
|
1445
|
+
s.value = { x: 2 };
|
|
1446
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it("signal with equals: false always notifies", () => {
|
|
1450
|
+
const s = signal(1, { equals: false });
|
|
1451
|
+
const spy = vi.fn();
|
|
1452
|
+
effect(() => {
|
|
1453
|
+
spy(s.value);
|
|
1454
|
+
});
|
|
1455
|
+
spy.mockClear();
|
|
1456
|
+
s.value = 1; // same value but equals:false → notify
|
|
1457
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it("computed with custom equals suppresses downstream", () => {
|
|
1461
|
+
const s = signal(1);
|
|
1462
|
+
const c = computed(() => ({ val: s.value, rounded: Math.floor(s.value) }), {
|
|
1463
|
+
equals: (a, b) => a.rounded === b.rounded,
|
|
1464
|
+
});
|
|
1465
|
+
const spy = vi.fn();
|
|
1466
|
+
effect(() => {
|
|
1467
|
+
spy(c.value.rounded);
|
|
1468
|
+
});
|
|
1469
|
+
spy.mockClear();
|
|
1470
|
+
s.value = 1.5; // rounded still 1
|
|
1471
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1472
|
+
s.value = 2.1; // rounded now 2
|
|
1473
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it("computed with equals: false always notifies", () => {
|
|
1477
|
+
const s = signal(1);
|
|
1478
|
+
const c = computed(() => s.value > 0, { equals: false });
|
|
1479
|
+
const spy = vi.fn();
|
|
1480
|
+
effect(() => {
|
|
1481
|
+
spy(c.value);
|
|
1482
|
+
});
|
|
1483
|
+
spy.mockClear();
|
|
1484
|
+
s.value = 2; // still true, but equals:false → notify
|
|
1485
|
+
expect(spy).toHaveBeenCalled();
|
|
1486
|
+
});
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
// ---------------------------------------------------------------------------
|
|
1490
|
+
// on() explicit dependency helper
|
|
1491
|
+
// ---------------------------------------------------------------------------
|
|
1492
|
+
|
|
1493
|
+
describe("on", () => {
|
|
1494
|
+
it("tracks only explicit deps, not body reads", () => {
|
|
1495
|
+
const a = signal(1);
|
|
1496
|
+
const b = signal(10);
|
|
1497
|
+
const spy = vi.fn();
|
|
1498
|
+
effect(
|
|
1499
|
+
on(
|
|
1500
|
+
() => a.value,
|
|
1501
|
+
(v) => {
|
|
1502
|
+
spy(v, b.value);
|
|
1503
|
+
return undefined;
|
|
1504
|
+
},
|
|
1505
|
+
),
|
|
1506
|
+
);
|
|
1507
|
+
expect(spy).toHaveBeenCalledWith(1, 10);
|
|
1508
|
+
spy.mockClear();
|
|
1509
|
+
b.value = 20; // not tracked
|
|
1510
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1511
|
+
a.value = 2; // tracked
|
|
1512
|
+
expect(spy).toHaveBeenCalledWith(2, 20);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
it("supports array of deps", () => {
|
|
1516
|
+
const a = signal(1);
|
|
1517
|
+
const b = signal(2);
|
|
1518
|
+
const spy = vi.fn();
|
|
1519
|
+
effect(
|
|
1520
|
+
on([() => a.value, () => b.value], (vals) => {
|
|
1521
|
+
spy(vals);
|
|
1522
|
+
return undefined;
|
|
1523
|
+
}),
|
|
1524
|
+
);
|
|
1525
|
+
expect(spy).toHaveBeenCalledWith([1, 2]);
|
|
1526
|
+
spy.mockClear();
|
|
1527
|
+
a.value = 10;
|
|
1528
|
+
expect(spy).toHaveBeenCalledWith([10, 2]);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it("defer option skips first execution", () => {
|
|
1532
|
+
const a = signal(1);
|
|
1533
|
+
const spy = vi.fn();
|
|
1534
|
+
effect(
|
|
1535
|
+
on(
|
|
1536
|
+
() => a.value,
|
|
1537
|
+
(v) => {
|
|
1538
|
+
spy(v);
|
|
1539
|
+
return undefined;
|
|
1540
|
+
},
|
|
1541
|
+
{ defer: true },
|
|
1542
|
+
),
|
|
1543
|
+
);
|
|
1544
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1545
|
+
a.value = 2;
|
|
1546
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it("provides prev value and prev result", () => {
|
|
1550
|
+
const s = signal(1);
|
|
1551
|
+
const results: unknown[] = [];
|
|
1552
|
+
effect(
|
|
1553
|
+
on(
|
|
1554
|
+
() => s.value,
|
|
1555
|
+
(val, prev, prevResult) => {
|
|
1556
|
+
results.push({ val, prev, prevResult });
|
|
1557
|
+
return () => {
|
|
1558
|
+
/* cleanup */
|
|
1559
|
+
};
|
|
1560
|
+
},
|
|
1561
|
+
),
|
|
1562
|
+
);
|
|
1563
|
+
s.value = 2;
|
|
1564
|
+
s.value = 3;
|
|
1565
|
+
expect(results).toEqual([
|
|
1566
|
+
{ val: 1, prev: undefined, prevResult: undefined },
|
|
1567
|
+
{ val: 2, prev: 1, prevResult: expect.any(Function) },
|
|
1568
|
+
{ val: 3, prev: 2, prevResult: expect.any(Function) },
|
|
1569
|
+
]);
|
|
1570
|
+
});
|
|
1571
|
+
});
|