@cplieger/reactive 1.0.1 → 1.0.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/jsr.json +1 -1
- package/package.json +4 -1
- package/src/invariants.test.ts +0 -483
- package/src/reactive.test.ts +0 -1571
- package/src/redteam.test.ts +0 -552
- package/src/redteam2.test.ts +0 -711
- package/src/redteam3.test.ts +0 -771
- package/src/redteam4.test.ts +0 -410
- package/src/redteam5.test.ts +0 -692
- package/src/redteam6.test.ts +0 -650
- package/src/redteam7.test.ts +0 -658
- package/src/redteam8-adversarial.test.ts +0 -1084
package/src/redteam3.test.ts
DELETED
|
@@ -1,771 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
// RED-TEAM round-3 final convergence sweep
|
|
3
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
4
|
-
import {
|
|
5
|
-
signal,
|
|
6
|
-
effect,
|
|
7
|
-
batch,
|
|
8
|
-
computed,
|
|
9
|
-
untracked,
|
|
10
|
-
subscribe,
|
|
11
|
-
setEffectErrorHandler,
|
|
12
|
-
createStore,
|
|
13
|
-
reconcile,
|
|
14
|
-
patch,
|
|
15
|
-
reconcileChildren,
|
|
16
|
-
} from "./index.js";
|
|
17
|
-
import type { EffectErrorHandler, ReadonlySignal } from "./index.js";
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// 1. Verify round-1/2 fixes are sound
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
describe("round-1/2 fix verification", () => {
|
|
24
|
-
let prevHandler: EffectErrorHandler;
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
prevHandler = setEffectErrorHandler(() => {
|
|
27
|
-
/* swallow */
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
setEffectErrorHandler(prevHandler);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("drainPending try/finally: flushing resets even with cascading throws", () => {
|
|
35
|
-
const s = signal(0);
|
|
36
|
-
// Create 3 effects that all throw
|
|
37
|
-
effect(() => {
|
|
38
|
-
if (s.value > 0) {
|
|
39
|
-
throw new Error("e1");
|
|
40
|
-
}
|
|
41
|
-
return undefined;
|
|
42
|
-
});
|
|
43
|
-
effect(() => {
|
|
44
|
-
if (s.value > 0) {
|
|
45
|
-
throw new Error("e2");
|
|
46
|
-
}
|
|
47
|
-
return undefined;
|
|
48
|
-
});
|
|
49
|
-
effect(() => {
|
|
50
|
-
if (s.value > 0) {
|
|
51
|
-
throw new Error("e3");
|
|
52
|
-
}
|
|
53
|
-
return undefined;
|
|
54
|
-
});
|
|
55
|
-
s.value = 1;
|
|
56
|
-
// System must still work
|
|
57
|
-
const spy = vi.fn();
|
|
58
|
-
effect(() => {
|
|
59
|
-
spy(s.value);
|
|
60
|
-
return undefined;
|
|
61
|
-
});
|
|
62
|
-
spy.mockClear();
|
|
63
|
-
s.value = 2;
|
|
64
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("dispose re-entrancy guard: rapid dispose-dispose-dispose", () => {
|
|
68
|
-
const s = signal(0);
|
|
69
|
-
let cleanups = 0;
|
|
70
|
-
const dispose = effect(() => {
|
|
71
|
-
void s.value;
|
|
72
|
-
return () => {
|
|
73
|
-
cleanups++;
|
|
74
|
-
};
|
|
75
|
-
});
|
|
76
|
-
dispose();
|
|
77
|
-
dispose();
|
|
78
|
-
dispose();
|
|
79
|
-
expect(cleanups).toBe(1);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("cleanup cleared before invocation prevents double-call on re-trigger", () => {
|
|
83
|
-
const s = signal(0);
|
|
84
|
-
let cleanups = 0;
|
|
85
|
-
effect(() => {
|
|
86
|
-
void s.value;
|
|
87
|
-
return () => {
|
|
88
|
-
cleanups++;
|
|
89
|
-
};
|
|
90
|
-
});
|
|
91
|
-
s.value = 1;
|
|
92
|
-
s.value = 2;
|
|
93
|
-
s.value = 3;
|
|
94
|
-
expect(cleanups).toBe(3); // exactly one per re-run
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
// 2. Pathological diamond/fan-out graphs
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
|
|
102
|
-
describe("pathological graphs", () => {
|
|
103
|
-
it("deep diamond (10 levels) fires leaf effect exactly once per change", () => {
|
|
104
|
-
const root = signal(0);
|
|
105
|
-
// Build diamond: each level splits into 2 computeds that merge at next level
|
|
106
|
-
let nodes: ReadonlySignal<number>[] = [computed(() => root.value)];
|
|
107
|
-
for (let level = 0; level < 10; level++) {
|
|
108
|
-
const next: ReadonlySignal<number>[] = [];
|
|
109
|
-
for (const n of nodes) {
|
|
110
|
-
next.push(computed(() => n.value + 1));
|
|
111
|
-
next.push(computed(() => n.value + 2));
|
|
112
|
-
}
|
|
113
|
-
// Merge pairs
|
|
114
|
-
const merged: ReadonlySignal<number>[] = [];
|
|
115
|
-
for (let i = 0; i < next.length; i += 2) {
|
|
116
|
-
merged.push(computed(() => next[i]!.value + next[i + 1]!.value));
|
|
117
|
-
}
|
|
118
|
-
nodes = merged;
|
|
119
|
-
}
|
|
120
|
-
const leaf = nodes[0]!;
|
|
121
|
-
const spy = vi.fn();
|
|
122
|
-
effect(() => {
|
|
123
|
-
spy(leaf.value);
|
|
124
|
-
return undefined;
|
|
125
|
-
});
|
|
126
|
-
spy.mockClear();
|
|
127
|
-
root.value = 1;
|
|
128
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("fan-out: 100 effects on same signal, all fire exactly once", () => {
|
|
132
|
-
const s = signal(0);
|
|
133
|
-
const spies = Array.from({ length: 100 }, () => vi.fn());
|
|
134
|
-
for (const spy of spies) {
|
|
135
|
-
effect(() => {
|
|
136
|
-
spy(s.value);
|
|
137
|
-
return undefined;
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
for (const spy of spies) {
|
|
141
|
-
spy.mockClear();
|
|
142
|
-
}
|
|
143
|
-
s.value = 1;
|
|
144
|
-
for (const spy of spies) {
|
|
145
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
146
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("fan-in: 50 signals → 1 computed → 1 effect, batch update all", () => {
|
|
151
|
-
const signals = Array.from({ length: 50 }, (_, i) => signal(i));
|
|
152
|
-
const sum = computed(() => signals.reduce((acc, s) => acc + s.value, 0));
|
|
153
|
-
const spy = vi.fn();
|
|
154
|
-
effect(() => {
|
|
155
|
-
spy(sum.value);
|
|
156
|
-
return undefined;
|
|
157
|
-
});
|
|
158
|
-
spy.mockClear();
|
|
159
|
-
batch(() => {
|
|
160
|
-
for (const s of signals) {
|
|
161
|
-
s.value = s.peek() + 1;
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
165
|
-
expect(spy).toHaveBeenCalledWith(1275); // original sum(0..49)=1225, each +1 → sum(1..50)=1275
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("computed cycle detection throws", () => {
|
|
169
|
-
// eslint-disable-next-line prefer-const
|
|
170
|
-
let b: ReadonlySignal<number>;
|
|
171
|
-
const a = computed(() => b.value + 1);
|
|
172
|
-
b = computed(() => a.value + 1);
|
|
173
|
-
expect(() => a.value).toThrow("Cycle detected");
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// ---------------------------------------------------------------------------
|
|
178
|
-
// 3. Exception in every callback type
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
|
|
181
|
-
describe("exception in every callback type", () => {
|
|
182
|
-
let prevHandler: EffectErrorHandler;
|
|
183
|
-
const errors: unknown[] = [];
|
|
184
|
-
beforeEach(() => {
|
|
185
|
-
errors.length = 0;
|
|
186
|
-
prevHandler = setEffectErrorHandler((e) => {
|
|
187
|
-
errors.push(e);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
afterEach(() => {
|
|
191
|
-
setEffectErrorHandler(prevHandler);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("exception in effect cleanup does not prevent re-execution", () => {
|
|
195
|
-
const s = signal(0);
|
|
196
|
-
const values: number[] = [];
|
|
197
|
-
effect(() => {
|
|
198
|
-
values.push(s.value);
|
|
199
|
-
return () => {
|
|
200
|
-
throw new Error("cleanup boom");
|
|
201
|
-
};
|
|
202
|
-
});
|
|
203
|
-
s.value = 1;
|
|
204
|
-
s.value = 2;
|
|
205
|
-
// Effect should still track and re-run despite cleanup throwing
|
|
206
|
-
expect(values).toContain(0);
|
|
207
|
-
expect(values).toContain(1);
|
|
208
|
-
expect(values).toContain(2);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("exception in computed fn is cached and re-thrown on read", () => {
|
|
212
|
-
const s = signal(0);
|
|
213
|
-
const c = computed(() => {
|
|
214
|
-
if (s.value === 1) {
|
|
215
|
-
throw new Error("computed boom");
|
|
216
|
-
}
|
|
217
|
-
return s.value * 2;
|
|
218
|
-
});
|
|
219
|
-
expect(c.value).toBe(0);
|
|
220
|
-
s.value = 1;
|
|
221
|
-
expect(() => c.value).toThrow("computed boom");
|
|
222
|
-
// Recovery
|
|
223
|
-
s.value = 2;
|
|
224
|
-
expect(c.value).toBe(4);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("exception in subscribe callback is caught by error handler", () => {
|
|
228
|
-
const s = signal(0);
|
|
229
|
-
subscribe(s, (v) => {
|
|
230
|
-
if (v === 1) {
|
|
231
|
-
throw new Error("subscribe boom");
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
s.value = 1;
|
|
235
|
-
expect(errors.some((e) => (e as Error).message === "subscribe boom")).toBe(true);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("exception in batch fn propagates but effects still flush", () => {
|
|
239
|
-
const s = signal(0);
|
|
240
|
-
const spy = vi.fn();
|
|
241
|
-
effect(() => {
|
|
242
|
-
spy(s.value);
|
|
243
|
-
return undefined;
|
|
244
|
-
});
|
|
245
|
-
spy.mockClear();
|
|
246
|
-
expect(() => {
|
|
247
|
-
batch(() => {
|
|
248
|
-
s.value = 99;
|
|
249
|
-
throw new Error("batch boom");
|
|
250
|
-
});
|
|
251
|
-
}).toThrow("batch boom");
|
|
252
|
-
// Effect should have flushed due to try/finally in batch
|
|
253
|
-
expect(spy).toHaveBeenCalledWith(99);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it("exception in on() body is caught by error handler", () => {
|
|
257
|
-
const s = signal(0);
|
|
258
|
-
const fn = vi.fn();
|
|
259
|
-
effect(() => {
|
|
260
|
-
const v = s.value;
|
|
261
|
-
if (v === 1) {
|
|
262
|
-
throw new Error("on body boom");
|
|
263
|
-
}
|
|
264
|
-
fn(v);
|
|
265
|
-
return undefined;
|
|
266
|
-
});
|
|
267
|
-
fn.mockClear();
|
|
268
|
-
s.value = 1;
|
|
269
|
-
expect(errors.some((e) => (e as Error).message === "on body boom")).toBe(true);
|
|
270
|
-
// Recovery
|
|
271
|
-
s.value = 2;
|
|
272
|
-
expect(fn).toHaveBeenCalledWith(2);
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
// 4. Dispose ordering
|
|
278
|
-
// ---------------------------------------------------------------------------
|
|
279
|
-
|
|
280
|
-
describe("dispose ordering", () => {
|
|
281
|
-
it("disposing effects in reverse order of creation", () => {
|
|
282
|
-
const s = signal(0);
|
|
283
|
-
const order: string[] = [];
|
|
284
|
-
const disposers = Array.from({ length: 5 }, (_, i) =>
|
|
285
|
-
effect(() => {
|
|
286
|
-
order.push(`e${i}:${s.value}`);
|
|
287
|
-
return () => {
|
|
288
|
-
order.push(`c${i}`);
|
|
289
|
-
};
|
|
290
|
-
}),
|
|
291
|
-
);
|
|
292
|
-
order.length = 0;
|
|
293
|
-
// Dispose in reverse
|
|
294
|
-
for (let i = 4; i >= 0; i--) {
|
|
295
|
-
disposers[i]!();
|
|
296
|
-
}
|
|
297
|
-
expect(order).toEqual(["c4", "c3", "c2", "c1", "c0"]);
|
|
298
|
-
// No effects should fire
|
|
299
|
-
order.length = 0;
|
|
300
|
-
s.value = 1;
|
|
301
|
-
expect(order).toEqual([]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("disposing during flush does not corrupt iteration", () => {
|
|
305
|
-
const s = signal(0);
|
|
306
|
-
const spy = vi.fn();
|
|
307
|
-
const disposers: (() => void)[] = [];
|
|
308
|
-
// Effect 0 disposes effects 1 and 2 when triggered
|
|
309
|
-
disposers.push(
|
|
310
|
-
effect(() => {
|
|
311
|
-
if (s.value === 1) {
|
|
312
|
-
disposers[1]?.();
|
|
313
|
-
disposers[2]?.();
|
|
314
|
-
}
|
|
315
|
-
return undefined;
|
|
316
|
-
}),
|
|
317
|
-
);
|
|
318
|
-
disposers.push(
|
|
319
|
-
effect(() => {
|
|
320
|
-
spy("e1:" + s.value);
|
|
321
|
-
return undefined;
|
|
322
|
-
}),
|
|
323
|
-
);
|
|
324
|
-
disposers.push(
|
|
325
|
-
effect(() => {
|
|
326
|
-
spy("e2:" + s.value);
|
|
327
|
-
return undefined;
|
|
328
|
-
}),
|
|
329
|
-
);
|
|
330
|
-
disposers.push(
|
|
331
|
-
effect(() => {
|
|
332
|
-
spy("e3:" + s.value);
|
|
333
|
-
return undefined;
|
|
334
|
-
}),
|
|
335
|
-
);
|
|
336
|
-
spy.mockClear();
|
|
337
|
-
s.value = 1;
|
|
338
|
-
// e3 should still fire; e1 and e2 may or may not depending on order
|
|
339
|
-
expect(spy).toHaveBeenCalledWith("e3:1");
|
|
340
|
-
});
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
// ---------------------------------------------------------------------------
|
|
344
|
-
// 5. Memory leak check after mass dispose
|
|
345
|
-
// ---------------------------------------------------------------------------
|
|
346
|
-
|
|
347
|
-
describe("memory leak after mass dispose", () => {
|
|
348
|
-
it("disposed effects are fully unlinked from signal subscriber sets", () => {
|
|
349
|
-
const s = signal(0);
|
|
350
|
-
const disposers: (() => void)[] = [];
|
|
351
|
-
for (let i = 0; i < 1000; i++) {
|
|
352
|
-
disposers.push(
|
|
353
|
-
effect(() => {
|
|
354
|
-
void s.value;
|
|
355
|
-
return undefined;
|
|
356
|
-
}),
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
// Dispose all
|
|
360
|
-
for (const d of disposers) {
|
|
361
|
-
d();
|
|
362
|
-
}
|
|
363
|
-
// Verify: setting signal should not trigger anything
|
|
364
|
-
const spy = vi.fn();
|
|
365
|
-
const d2 = effect(() => {
|
|
366
|
-
spy(s.value);
|
|
367
|
-
return undefined;
|
|
368
|
-
});
|
|
369
|
-
spy.mockClear();
|
|
370
|
-
s.value = 1;
|
|
371
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
372
|
-
d2();
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it("disposed computed does not retain references to source signals", () => {
|
|
376
|
-
const s = signal(0);
|
|
377
|
-
const computeds: ReturnType<typeof computed>[] = [];
|
|
378
|
-
const disposers: (() => void)[] = [];
|
|
379
|
-
for (let i = 0; i < 100; i++) {
|
|
380
|
-
const c = computed(() => s.value + i);
|
|
381
|
-
computeds.push(c);
|
|
382
|
-
disposers.push(
|
|
383
|
-
effect(() => {
|
|
384
|
-
void c.value;
|
|
385
|
-
return undefined;
|
|
386
|
-
}),
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
// Dispose all effects (computeds become lazy/unsubscribed)
|
|
390
|
-
for (const d of disposers) {
|
|
391
|
-
d();
|
|
392
|
-
}
|
|
393
|
-
// Signal update should not trigger any computation
|
|
394
|
-
const spy = vi.fn();
|
|
395
|
-
const d2 = effect(() => {
|
|
396
|
-
spy(s.value);
|
|
397
|
-
return undefined;
|
|
398
|
-
});
|
|
399
|
-
spy.mockClear();
|
|
400
|
-
s.value = 99;
|
|
401
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
402
|
-
expect(spy).toHaveBeenCalledWith(99);
|
|
403
|
-
d2();
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
// ---------------------------------------------------------------------------
|
|
408
|
-
// 6. Batch + computed + effect interleaving
|
|
409
|
-
// ---------------------------------------------------------------------------
|
|
410
|
-
|
|
411
|
-
describe("batch + computed + effect interleaving", () => {
|
|
412
|
-
it("batch with computed read mid-batch sees consistent state after flush", () => {
|
|
413
|
-
const a = signal(1);
|
|
414
|
-
const b = signal(2);
|
|
415
|
-
const sum = computed(() => a.value + b.value);
|
|
416
|
-
const values: number[] = [];
|
|
417
|
-
effect(() => {
|
|
418
|
-
values.push(sum.value);
|
|
419
|
-
return undefined;
|
|
420
|
-
});
|
|
421
|
-
values.length = 0;
|
|
422
|
-
batch(() => {
|
|
423
|
-
a.value = 10;
|
|
424
|
-
b.value = 20;
|
|
425
|
-
});
|
|
426
|
-
// After batch: effect fires with final value
|
|
427
|
-
expect(values).toContain(30); // 10 + 20
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
it("nested batch: inner batch does not flush", () => {
|
|
431
|
-
const s = signal(0);
|
|
432
|
-
const spy = vi.fn();
|
|
433
|
-
effect(() => {
|
|
434
|
-
spy(s.value);
|
|
435
|
-
return undefined;
|
|
436
|
-
});
|
|
437
|
-
spy.mockClear();
|
|
438
|
-
batch(() => {
|
|
439
|
-
batch(() => {
|
|
440
|
-
s.value = 1;
|
|
441
|
-
});
|
|
442
|
-
// Inner batch ended but outer still active - no flush yet
|
|
443
|
-
expect(spy).not.toHaveBeenCalled();
|
|
444
|
-
s.value = 2;
|
|
445
|
-
});
|
|
446
|
-
// Only final value should be seen
|
|
447
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
448
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
it("effect creating new effect during batch flush", () => {
|
|
452
|
-
const s = signal(0);
|
|
453
|
-
const inner = vi.fn();
|
|
454
|
-
effect(() => {
|
|
455
|
-
if (s.value === 1) {
|
|
456
|
-
// Create new effect during flush
|
|
457
|
-
effect(() => {
|
|
458
|
-
inner(s.value);
|
|
459
|
-
return undefined;
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
return undefined;
|
|
463
|
-
});
|
|
464
|
-
batch(() => {
|
|
465
|
-
s.value = 1;
|
|
466
|
-
});
|
|
467
|
-
expect(inner).toHaveBeenCalledWith(1);
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
it("computed invalidation during batch does not cause double-fire", () => {
|
|
471
|
-
const a = signal(0);
|
|
472
|
-
const b = signal(0);
|
|
473
|
-
const c = computed(() => a.value + b.value);
|
|
474
|
-
const spy = vi.fn();
|
|
475
|
-
effect(() => {
|
|
476
|
-
spy(c.value);
|
|
477
|
-
return undefined;
|
|
478
|
-
});
|
|
479
|
-
spy.mockClear();
|
|
480
|
-
batch(() => {
|
|
481
|
-
a.value = 1;
|
|
482
|
-
b.value = 1;
|
|
483
|
-
});
|
|
484
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
485
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
486
|
-
});
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
// ---------------------------------------------------------------------------
|
|
490
|
-
// 7. Reconcile fuzz
|
|
491
|
-
// ---------------------------------------------------------------------------
|
|
492
|
-
|
|
493
|
-
describe("reconcile fuzz", () => {
|
|
494
|
-
function makeSpec() {
|
|
495
|
-
return {
|
|
496
|
-
key: (i: { id: string }) => i.id,
|
|
497
|
-
mount: (i: { id: string; v: number }) => {
|
|
498
|
-
const el = document.createElement("div");
|
|
499
|
-
el.textContent = String(i.v);
|
|
500
|
-
return el;
|
|
501
|
-
},
|
|
502
|
-
update: (el: HTMLElement, i: { id: string; v: number }) => {
|
|
503
|
-
el.textContent = String(i.v);
|
|
504
|
-
},
|
|
505
|
-
onRemove: vi.fn(),
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
it("random insert/remove/reorder 50 iterations", () => {
|
|
510
|
-
const parent = document.createElement("div");
|
|
511
|
-
const spec = makeSpec();
|
|
512
|
-
const items: { id: string; v: number }[] = [];
|
|
513
|
-
let nextId = 0;
|
|
514
|
-
let seed = 42;
|
|
515
|
-
const rng = () => {
|
|
516
|
-
seed = (seed * 1664525 + 1013904223) >>> 0;
|
|
517
|
-
return seed / 0x100000000;
|
|
518
|
-
};
|
|
519
|
-
|
|
520
|
-
for (let iter = 0; iter < 50; iter++) {
|
|
521
|
-
const action = rng();
|
|
522
|
-
if (action < 0.3 && items.length > 0) {
|
|
523
|
-
// Remove random
|
|
524
|
-
const idx = Math.floor(rng() * items.length);
|
|
525
|
-
items.splice(idx, 1);
|
|
526
|
-
} else if (action < 0.6) {
|
|
527
|
-
// Insert random
|
|
528
|
-
const idx = Math.floor(rng() * (items.length + 1));
|
|
529
|
-
items.splice(idx, 0, { id: `id${nextId++}`, v: iter });
|
|
530
|
-
} else if (items.length > 1) {
|
|
531
|
-
// Swap two random
|
|
532
|
-
const i = Math.floor(rng() * items.length);
|
|
533
|
-
let j = Math.floor(rng() * items.length);
|
|
534
|
-
if (j === i) {
|
|
535
|
-
j = (j + 1) % items.length;
|
|
536
|
-
}
|
|
537
|
-
[items[i], items[j]] = [items[j]!, items[i]!];
|
|
538
|
-
}
|
|
539
|
-
reconcile(parent, items, spec);
|
|
540
|
-
expect(parent.children.length).toBe(items.length);
|
|
541
|
-
// Verify order
|
|
542
|
-
for (let k = 0; k < items.length; k++) {
|
|
543
|
-
expect(parent.children[k]!.getAttribute("data-reconcile-key")).toBe(items[k]!.id);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
it("reconcile with empty list clears all", () => {
|
|
549
|
-
const parent = document.createElement("div");
|
|
550
|
-
const spec = makeSpec();
|
|
551
|
-
const items = Array.from({ length: 20 }, (_, i) => ({ id: `k${i}`, v: i }));
|
|
552
|
-
reconcile(parent, items, spec);
|
|
553
|
-
expect(parent.children.length).toBe(20);
|
|
554
|
-
reconcile(parent, [], spec);
|
|
555
|
-
expect(parent.children.length).toBe(0);
|
|
556
|
-
expect(spec.onRemove).toHaveBeenCalledTimes(20);
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
it("reconcile with duplicate keys (pathological input)", () => {
|
|
560
|
-
const parent = document.createElement("div");
|
|
561
|
-
const spec = makeSpec();
|
|
562
|
-
// Duplicate keys - library should handle gracefully (last wins or first wins)
|
|
563
|
-
const items = [
|
|
564
|
-
{ id: "dup", v: 1 },
|
|
565
|
-
{ id: "dup", v: 2 },
|
|
566
|
-
{ id: "unique", v: 3 },
|
|
567
|
-
];
|
|
568
|
-
// Should not throw
|
|
569
|
-
expect(() => reconcile(parent, items, spec)).not.toThrow();
|
|
570
|
-
});
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
// ---------------------------------------------------------------------------
|
|
574
|
-
// 8. Fake timers: effect scheduling is synchronous (no microtask leaks)
|
|
575
|
-
// ---------------------------------------------------------------------------
|
|
576
|
-
|
|
577
|
-
describe("synchronous semantics (fake timers)", () => {
|
|
578
|
-
beforeEach(() => {
|
|
579
|
-
vi.useFakeTimers();
|
|
580
|
-
});
|
|
581
|
-
afterEach(() => {
|
|
582
|
-
vi.useRealTimers();
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
it("signal write + effect is fully synchronous, no pending microtasks", () => {
|
|
586
|
-
const s = signal(0);
|
|
587
|
-
const spy = vi.fn();
|
|
588
|
-
effect(() => {
|
|
589
|
-
spy(s.value);
|
|
590
|
-
return undefined;
|
|
591
|
-
});
|
|
592
|
-
spy.mockClear();
|
|
593
|
-
s.value = 1;
|
|
594
|
-
// Should already have fired - no need to advance timers
|
|
595
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it("batch flush is synchronous", () => {
|
|
599
|
-
const s = signal(0);
|
|
600
|
-
const spy = vi.fn();
|
|
601
|
-
effect(() => {
|
|
602
|
-
spy(s.value);
|
|
603
|
-
return undefined;
|
|
604
|
-
});
|
|
605
|
-
spy.mockClear();
|
|
606
|
-
batch(() => {
|
|
607
|
-
s.value = 42;
|
|
608
|
-
});
|
|
609
|
-
expect(spy).toHaveBeenCalledWith(42);
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
it("computed is evaluated lazily and synchronously", () => {
|
|
613
|
-
const s = signal(1);
|
|
614
|
-
const c = computed(() => s.value * 2);
|
|
615
|
-
s.value = 5;
|
|
616
|
-
expect(c.value).toBe(10); // No timer needed
|
|
617
|
-
});
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
// ---------------------------------------------------------------------------
|
|
621
|
-
// 9. untracked edge cases
|
|
622
|
-
// ---------------------------------------------------------------------------
|
|
623
|
-
|
|
624
|
-
describe("untracked edge cases", () => {
|
|
625
|
-
it("untracked inside effect prevents tracking", () => {
|
|
626
|
-
const a = signal(0);
|
|
627
|
-
const b = signal(0);
|
|
628
|
-
const spy = vi.fn();
|
|
629
|
-
effect(() => {
|
|
630
|
-
const av = a.value; // tracked
|
|
631
|
-
const bv = untracked(() => b.value); // not tracked
|
|
632
|
-
spy(av, bv);
|
|
633
|
-
return undefined;
|
|
634
|
-
});
|
|
635
|
-
spy.mockClear();
|
|
636
|
-
b.value = 99;
|
|
637
|
-
expect(spy).not.toHaveBeenCalled();
|
|
638
|
-
a.value = 1;
|
|
639
|
-
expect(spy).toHaveBeenCalledWith(1, 99);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it("untracked with throwing fn restores tracking context", () => {
|
|
643
|
-
const s = signal(0);
|
|
644
|
-
const spy = vi.fn();
|
|
645
|
-
effect(() => {
|
|
646
|
-
try {
|
|
647
|
-
untracked(() => {
|
|
648
|
-
throw new Error("boom");
|
|
649
|
-
});
|
|
650
|
-
} catch {
|
|
651
|
-
/* swallow */
|
|
652
|
-
}
|
|
653
|
-
spy(s.value); // should still be tracked
|
|
654
|
-
return undefined;
|
|
655
|
-
});
|
|
656
|
-
spy.mockClear();
|
|
657
|
-
s.value = 1;
|
|
658
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
659
|
-
});
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
// ---------------------------------------------------------------------------
|
|
663
|
-
// 10. Store: batch + effect + computed interleaving
|
|
664
|
-
// ---------------------------------------------------------------------------
|
|
665
|
-
|
|
666
|
-
describe("store: batch + effect + computed interleaving", () => {
|
|
667
|
-
it("store computed updates atomically in batch", () => {
|
|
668
|
-
const store = createStore<{ a: number; b: number; sum: number }>();
|
|
669
|
-
store.set("a", 1);
|
|
670
|
-
store.set("b", 2);
|
|
671
|
-
store.computed("sum", () => store.get("a") + store.get("b"));
|
|
672
|
-
expect(store.get("sum")).toBe(3);
|
|
673
|
-
const spy = vi.fn();
|
|
674
|
-
store.subscribe("sum", spy);
|
|
675
|
-
store.batch(() => {
|
|
676
|
-
store.set("a", 10);
|
|
677
|
-
store.set("b", 20);
|
|
678
|
-
});
|
|
679
|
-
// sum should update to 30
|
|
680
|
-
expect(store.get("sum")).toBe(30);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
it("store effect throwing does not break subsequent effects", () => {
|
|
684
|
-
const store = createStore<{ x: number }>();
|
|
685
|
-
store.set("x", 0);
|
|
686
|
-
const spy = vi.fn();
|
|
687
|
-
store.effect(() => {
|
|
688
|
-
if (store.get("x") === 1) {
|
|
689
|
-
throw new Error("store effect boom");
|
|
690
|
-
}
|
|
691
|
-
return undefined;
|
|
692
|
-
});
|
|
693
|
-
store.effect(() => {
|
|
694
|
-
spy(store.get("x"));
|
|
695
|
-
return undefined;
|
|
696
|
-
});
|
|
697
|
-
spy.mockClear();
|
|
698
|
-
// The throwing effect will console.error but shouldn't prevent spy effect
|
|
699
|
-
const origError = console.error;
|
|
700
|
-
console.error = vi.fn();
|
|
701
|
-
store.set("x", 1);
|
|
702
|
-
console.error = origError;
|
|
703
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
704
|
-
});
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
// ---------------------------------------------------------------------------
|
|
708
|
-
// 11. Signal equality: custom equals with side effects (adversarial)
|
|
709
|
-
// ---------------------------------------------------------------------------
|
|
710
|
-
|
|
711
|
-
describe("adversarial equals", () => {
|
|
712
|
-
it("equals that reads another signal does not corrupt tracking", () => {
|
|
713
|
-
const a = signal(0);
|
|
714
|
-
const b = signal(0, {
|
|
715
|
-
equals: (prev, next) => {
|
|
716
|
-
// Adversarial: read another signal inside equals
|
|
717
|
-
void a.peek(); // peek is safe
|
|
718
|
-
return prev === next;
|
|
719
|
-
},
|
|
720
|
-
});
|
|
721
|
-
const spy = vi.fn();
|
|
722
|
-
effect(() => {
|
|
723
|
-
spy(b.value);
|
|
724
|
-
return undefined;
|
|
725
|
-
});
|
|
726
|
-
spy.mockClear();
|
|
727
|
-
b.value = 1;
|
|
728
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
it("equals returning false always causes notification", () => {
|
|
732
|
-
const s = signal(0, { equals: false });
|
|
733
|
-
const spy = vi.fn();
|
|
734
|
-
effect(() => {
|
|
735
|
-
spy(s.value);
|
|
736
|
-
return undefined;
|
|
737
|
-
});
|
|
738
|
-
spy.mockClear();
|
|
739
|
-
s.value = 0; // same value but equals=false means always notify
|
|
740
|
-
expect(spy).toHaveBeenCalledWith(0);
|
|
741
|
-
});
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
// ---------------------------------------------------------------------------
|
|
745
|
-
// 12. reconcileChildren / patch edge cases
|
|
746
|
-
// ---------------------------------------------------------------------------
|
|
747
|
-
|
|
748
|
-
describe("reconcileChildren / patch edge cases", () => {
|
|
749
|
-
it("patch with null/undefined children is no-op", () => {
|
|
750
|
-
const parent = document.createElement("div");
|
|
751
|
-
parent.textContent = "hello";
|
|
752
|
-
patch(parent, null, undefined);
|
|
753
|
-
expect(parent.childNodes.length).toBe(0);
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
it("patch with DocumentFragment spreads children", () => {
|
|
757
|
-
const parent = document.createElement("div");
|
|
758
|
-
const frag = document.createDocumentFragment();
|
|
759
|
-
frag.appendChild(document.createElement("span"));
|
|
760
|
-
frag.appendChild(document.createTextNode("text"));
|
|
761
|
-
patch(parent, frag);
|
|
762
|
-
expect(parent.childNodes.length).toBe(2);
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
it("reconcileChildren handles text node update", () => {
|
|
766
|
-
const parent = document.createElement("div");
|
|
767
|
-
parent.appendChild(document.createTextNode("old"));
|
|
768
|
-
reconcileChildren(parent, [document.createTextNode("new")]);
|
|
769
|
-
expect(parent.textContent).toBe("new");
|
|
770
|
-
});
|
|
771
|
-
});
|