@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,711 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// RED-TEAM round-2 adversarial tests
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
signal,
|
|
6
|
+
effect,
|
|
7
|
+
batch,
|
|
8
|
+
computed,
|
|
9
|
+
on,
|
|
10
|
+
setEffectErrorHandler,
|
|
11
|
+
flushSync,
|
|
12
|
+
createStore,
|
|
13
|
+
reconcile,
|
|
14
|
+
} from "./index.js";
|
|
15
|
+
import type { EffectErrorHandler } from "./index.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Verify round-1 fixes: drainPending try/finally + equals-throw
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
describe("round-1 fix verification: drainPending invariants", () => {
|
|
22
|
+
let prevHandler: EffectErrorHandler;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
prevHandler = setEffectErrorHandler(() => {
|
|
25
|
+
/* swallow */
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
setEffectErrorHandler(prevHandler);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("flushing flag resets after effect throws", () => {
|
|
33
|
+
const s = signal(0);
|
|
34
|
+
let threw = false;
|
|
35
|
+
effect(() => {
|
|
36
|
+
if (s.value === 1 && !threw) {
|
|
37
|
+
threw = true;
|
|
38
|
+
throw new Error("effect boom");
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
});
|
|
42
|
+
s.value = 1; // triggers throw inside drainPending
|
|
43
|
+
// System should still work - flushing must be false
|
|
44
|
+
const spy = vi.fn();
|
|
45
|
+
effect(() => {
|
|
46
|
+
spy(s.value);
|
|
47
|
+
return undefined;
|
|
48
|
+
});
|
|
49
|
+
spy.mockClear();
|
|
50
|
+
s.value = 2;
|
|
51
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("batchDepth resets after nested batch throws at each level", () => {
|
|
55
|
+
const s = signal(0);
|
|
56
|
+
const spy = vi.fn();
|
|
57
|
+
effect(() => {
|
|
58
|
+
spy(s.value);
|
|
59
|
+
return undefined;
|
|
60
|
+
});
|
|
61
|
+
spy.mockClear();
|
|
62
|
+
|
|
63
|
+
// Nested throws at each level
|
|
64
|
+
try {
|
|
65
|
+
batch(() => {
|
|
66
|
+
s.value = 1;
|
|
67
|
+
try {
|
|
68
|
+
batch(() => {
|
|
69
|
+
s.value = 2;
|
|
70
|
+
try {
|
|
71
|
+
batch(() => {
|
|
72
|
+
s.value = 3;
|
|
73
|
+
throw new Error("level 3");
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
/* swallow */
|
|
77
|
+
}
|
|
78
|
+
throw new Error("level 2");
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
/* swallow */
|
|
82
|
+
}
|
|
83
|
+
throw new Error("level 1");
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
/* swallow */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// batchDepth must be 0 - subsequent writes flush immediately
|
|
90
|
+
spy.mockClear();
|
|
91
|
+
s.value = 99;
|
|
92
|
+
expect(spy).toHaveBeenCalledWith(99);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("equals-throw in signal does not corrupt flushing state", () => {
|
|
96
|
+
const s = signal(1, {
|
|
97
|
+
equals: (_prev, next) => {
|
|
98
|
+
if (next === 42) {
|
|
99
|
+
throw new Error("equals boom");
|
|
100
|
+
}
|
|
101
|
+
return false; // always notify
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const spy = vi.fn();
|
|
105
|
+
effect(() => {
|
|
106
|
+
spy(s.value);
|
|
107
|
+
return undefined;
|
|
108
|
+
});
|
|
109
|
+
spy.mockClear();
|
|
110
|
+
|
|
111
|
+
expect(() => {
|
|
112
|
+
s.value = 42;
|
|
113
|
+
}).toThrow("equals boom");
|
|
114
|
+
|
|
115
|
+
// System should still work
|
|
116
|
+
s.value = 2;
|
|
117
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Exception in effect body (not equals)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe("exception in effect body", () => {
|
|
126
|
+
let prevHandler: EffectErrorHandler;
|
|
127
|
+
const errors: unknown[] = [];
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
errors.length = 0;
|
|
130
|
+
prevHandler = setEffectErrorHandler((e) => {
|
|
131
|
+
errors.push(e);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
setEffectErrorHandler(prevHandler);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("effect error does not prevent other pending effects from running", () => {
|
|
139
|
+
const s = signal(0);
|
|
140
|
+
const spy1 = vi.fn();
|
|
141
|
+
const spy2 = vi.fn();
|
|
142
|
+
|
|
143
|
+
// Effect 1 throws
|
|
144
|
+
effect(() => {
|
|
145
|
+
if (s.value === 1) {
|
|
146
|
+
throw new Error("e1 boom");
|
|
147
|
+
}
|
|
148
|
+
spy1(s.value);
|
|
149
|
+
return undefined;
|
|
150
|
+
});
|
|
151
|
+
// Effect 2 should still run
|
|
152
|
+
effect(() => {
|
|
153
|
+
spy2(s.value);
|
|
154
|
+
return undefined;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
spy1.mockClear();
|
|
158
|
+
spy2.mockClear();
|
|
159
|
+
s.value = 1;
|
|
160
|
+
expect(spy2).toHaveBeenCalledWith(1);
|
|
161
|
+
expect(errors.length).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("effect error during batch does not prevent flush of remaining effects", () => {
|
|
165
|
+
const a = signal(0);
|
|
166
|
+
const b = signal(0);
|
|
167
|
+
const spy = vi.fn();
|
|
168
|
+
|
|
169
|
+
effect(() => {
|
|
170
|
+
if (a.value === 1) {
|
|
171
|
+
throw new Error("a boom");
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
});
|
|
175
|
+
effect(() => {
|
|
176
|
+
spy(b.value);
|
|
177
|
+
return undefined;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
spy.mockClear();
|
|
181
|
+
batch(() => {
|
|
182
|
+
a.value = 1;
|
|
183
|
+
b.value = 1;
|
|
184
|
+
});
|
|
185
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Effect that writes the signal it reads (self-trigger loop bound)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe("self-trigger loop bound", () => {
|
|
194
|
+
it("effect writing its own signal converges (bounded iterations)", () => {
|
|
195
|
+
const s = signal(0);
|
|
196
|
+
let runs = 0;
|
|
197
|
+
effect(() => {
|
|
198
|
+
runs++;
|
|
199
|
+
if (s.value < 100) {
|
|
200
|
+
s.value = s.value + 1;
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
});
|
|
204
|
+
// Should converge at 100, not infinite loop
|
|
205
|
+
expect(runs).toBe(101);
|
|
206
|
+
expect(s.peek()).toBe(100);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("effect writing its own signal in batch converges", () => {
|
|
210
|
+
const s = signal(0);
|
|
211
|
+
let runs = 0;
|
|
212
|
+
effect(() => {
|
|
213
|
+
runs++;
|
|
214
|
+
const v = s.value;
|
|
215
|
+
if (v < 50) {
|
|
216
|
+
batch(() => {
|
|
217
|
+
s.value = v + 1;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
});
|
|
222
|
+
expect(runs).toBe(51);
|
|
223
|
+
expect(s.peek()).toBe(50);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Computed depending on computed depending on signal with throwing equals
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
describe("computed chain with throwing equals", () => {
|
|
232
|
+
it("computed → computed → signal, inner equals throws", () => {
|
|
233
|
+
const s = signal(1);
|
|
234
|
+
let throwOnEquals = false;
|
|
235
|
+
const inner = computed(() => s.value * 2, {
|
|
236
|
+
equals: () => {
|
|
237
|
+
if (throwOnEquals) {
|
|
238
|
+
throw new Error("inner eq boom");
|
|
239
|
+
}
|
|
240
|
+
return false; // always changed
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const outer = computed(() => inner.value + 10);
|
|
244
|
+
const spy = vi.fn();
|
|
245
|
+
effect(() => {
|
|
246
|
+
spy(outer.value);
|
|
247
|
+
return undefined;
|
|
248
|
+
});
|
|
249
|
+
expect(spy).toHaveBeenCalledWith(12); // 1*2+10
|
|
250
|
+
|
|
251
|
+
spy.mockClear();
|
|
252
|
+
throwOnEquals = true;
|
|
253
|
+
s.value = 2;
|
|
254
|
+
// inner.execute() catches the equals throw and treats as changed
|
|
255
|
+
// outer should still update
|
|
256
|
+
expect(spy).toHaveBeenCalledWith(14); // 2*2+10
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("deeply chained computeds with middle one throwing equals", () => {
|
|
260
|
+
const s = signal(1);
|
|
261
|
+
const c1 = computed(() => s.value);
|
|
262
|
+
const c2 = computed(() => c1.value * 2, {
|
|
263
|
+
equals: () => {
|
|
264
|
+
throw new Error("c2 eq");
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
const c3 = computed(() => c2.value + 100);
|
|
268
|
+
const spy = vi.fn();
|
|
269
|
+
effect(() => {
|
|
270
|
+
spy(c3.value);
|
|
271
|
+
return undefined;
|
|
272
|
+
});
|
|
273
|
+
expect(spy).toHaveBeenCalledWith(102); // 1*2+100
|
|
274
|
+
|
|
275
|
+
spy.mockClear();
|
|
276
|
+
s.value = 5;
|
|
277
|
+
expect(spy).toHaveBeenCalledWith(110); // 5*2+100
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// dispose() called from inside another effect
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
describe("dispose from inside another effect", () => {
|
|
286
|
+
it("disposing effect B from inside effect A does not crash", () => {
|
|
287
|
+
const s = signal(0);
|
|
288
|
+
const spyB = vi.fn();
|
|
289
|
+
const disposeB = effect(() => {
|
|
290
|
+
spyB(s.value);
|
|
291
|
+
return undefined;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
effect(() => {
|
|
295
|
+
if (s.value === 1) {
|
|
296
|
+
disposeB(); // dispose B from inside A's execution
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
spyB.mockClear();
|
|
302
|
+
s.value = 1; // A runs and disposes B; B should not run after disposal
|
|
303
|
+
// B might have already run for value=1 before A disposes it (order-dependent)
|
|
304
|
+
// But after this, B should definitely not run again
|
|
305
|
+
spyB.mockClear();
|
|
306
|
+
s.value = 2;
|
|
307
|
+
expect(spyB).not.toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("disposing self from cleanup does not crash or overflow stack", () => {
|
|
311
|
+
const s = signal(0);
|
|
312
|
+
let disposeRef: (() => void) | null = null;
|
|
313
|
+
const errors: unknown[] = [];
|
|
314
|
+
const prev = setEffectErrorHandler((e) => {
|
|
315
|
+
errors.push(e);
|
|
316
|
+
});
|
|
317
|
+
disposeRef = effect(() => {
|
|
318
|
+
void s.value;
|
|
319
|
+
return () => {
|
|
320
|
+
// cleanup tries to dispose self (already being disposed)
|
|
321
|
+
if (disposeRef) {
|
|
322
|
+
disposeRef();
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
// Trigger re-run (which calls cleanup which calls dispose)
|
|
327
|
+
s.value = 1;
|
|
328
|
+
// Should not have stack overflow error
|
|
329
|
+
expect(errors.every((e) => !(e instanceof RangeError))).toBe(true);
|
|
330
|
+
setEffectErrorHandler(prev);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// on() with empty deps
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
describe("on() with empty deps", () => {
|
|
339
|
+
it("on() with empty array deps runs once and never re-triggers", () => {
|
|
340
|
+
const s = signal(0);
|
|
341
|
+
const spy = vi.fn();
|
|
342
|
+
effect(
|
|
343
|
+
on([] as (() => unknown)[], (vals) => {
|
|
344
|
+
// Read s inside body (should not be tracked due to untracked in on())
|
|
345
|
+
spy(vals, s.value);
|
|
346
|
+
return undefined;
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
expect(spy).toHaveBeenCalledWith([], 0);
|
|
350
|
+
spy.mockClear();
|
|
351
|
+
s.value = 1;
|
|
352
|
+
expect(spy).not.toHaveBeenCalled();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("on() with single dep returning undefined", () => {
|
|
356
|
+
const s = signal<undefined>(undefined);
|
|
357
|
+
const spy = vi.fn();
|
|
358
|
+
effect(
|
|
359
|
+
on(
|
|
360
|
+
() => s.value,
|
|
361
|
+
(v) => {
|
|
362
|
+
spy(v);
|
|
363
|
+
return undefined;
|
|
364
|
+
},
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
expect(spy).toHaveBeenCalledWith(undefined);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Store deep nesting
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
describe("store deep nesting", () => {
|
|
376
|
+
it("deeply nested batch (10 levels) still flushes correctly", () => {
|
|
377
|
+
const store = createStore<{ x: number }>();
|
|
378
|
+
const spy = vi.fn();
|
|
379
|
+
store.subscribe("x", spy);
|
|
380
|
+
|
|
381
|
+
const nest = (depth: number, fn: () => void): void => {
|
|
382
|
+
if (depth === 0) {
|
|
383
|
+
fn();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
store.batch(() => nest(depth - 1, fn));
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
nest(10, () => {
|
|
390
|
+
store.set("x", 42);
|
|
391
|
+
});
|
|
392
|
+
expect(spy).toHaveBeenCalledWith(42);
|
|
393
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("store effect with many keys tracked", () => {
|
|
397
|
+
const store = createStore<Record<string, number>>();
|
|
398
|
+
for (let i = 0; i < 100; i++) {
|
|
399
|
+
store.set(`k${i}` as never, i as never);
|
|
400
|
+
}
|
|
401
|
+
const spy = vi.fn();
|
|
402
|
+
store.effect(() => {
|
|
403
|
+
let sum = 0;
|
|
404
|
+
for (let i = 0; i < 100; i++) {
|
|
405
|
+
sum += store.get(`k${i}` as never) as number;
|
|
406
|
+
}
|
|
407
|
+
spy(sum);
|
|
408
|
+
return undefined;
|
|
409
|
+
});
|
|
410
|
+
expect(spy).toHaveBeenCalledWith(4950); // sum 0..99
|
|
411
|
+
spy.mockClear();
|
|
412
|
+
store.set("k0" as never, 100 as never);
|
|
413
|
+
expect(spy).toHaveBeenCalledWith(5050); // 4950 - 0 + 100
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Reconcile with thousands of keyed nodes / moved + removed combos
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
describe("reconcile: large scale", () => {
|
|
422
|
+
it("1000 keyed nodes: full reverse", () => {
|
|
423
|
+
const parent = document.createElement("div");
|
|
424
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `k${i}`, label: `L${i}` }));
|
|
425
|
+
const spec = {
|
|
426
|
+
key: (i: { id: string; label: string }) => i.id,
|
|
427
|
+
mount: (i: { id: string; label: string }) => {
|
|
428
|
+
const el = document.createElement("span");
|
|
429
|
+
el.textContent = i.label;
|
|
430
|
+
return el;
|
|
431
|
+
},
|
|
432
|
+
update: (el: HTMLElement, i: { id: string; label: string }) => {
|
|
433
|
+
el.textContent = i.label;
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
reconcile(parent, items, spec);
|
|
438
|
+
expect(parent.children.length).toBe(1000);
|
|
439
|
+
|
|
440
|
+
// Capture references
|
|
441
|
+
const firstEl = parent.children[0]!;
|
|
442
|
+
const lastEl = parent.children[999]!;
|
|
443
|
+
|
|
444
|
+
// Full reverse
|
|
445
|
+
const reversed = [...items].reverse();
|
|
446
|
+
reconcile(parent, reversed, spec);
|
|
447
|
+
expect(parent.children.length).toBe(1000);
|
|
448
|
+
expect(parent.children[0]).toBe(lastEl);
|
|
449
|
+
expect(parent.children[999]).toBe(firstEl);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("1000 nodes: remove every other, then re-add", () => {
|
|
453
|
+
const parent = document.createElement("div");
|
|
454
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `k${i}`, label: `L${i}` }));
|
|
455
|
+
const removed: string[] = [];
|
|
456
|
+
const spec = {
|
|
457
|
+
key: (i: { id: string; label: string }) => i.id,
|
|
458
|
+
mount: (i: { id: string; label: string }) => {
|
|
459
|
+
const el = document.createElement("span");
|
|
460
|
+
el.textContent = i.label;
|
|
461
|
+
return el;
|
|
462
|
+
},
|
|
463
|
+
update: (el: HTMLElement, i: { id: string; label: string }) => {
|
|
464
|
+
el.textContent = i.label;
|
|
465
|
+
},
|
|
466
|
+
onRemove: (_el: HTMLElement, key: string) => {
|
|
467
|
+
removed.push(key);
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
reconcile(parent, items, spec);
|
|
472
|
+
|
|
473
|
+
// Remove every other
|
|
474
|
+
const evens = items.filter((_, i) => i % 2 === 0);
|
|
475
|
+
reconcile(parent, evens, spec);
|
|
476
|
+
expect(parent.children.length).toBe(500);
|
|
477
|
+
expect(removed.length).toBe(500);
|
|
478
|
+
|
|
479
|
+
// Re-add all
|
|
480
|
+
removed.length = 0;
|
|
481
|
+
reconcile(parent, items, spec);
|
|
482
|
+
expect(parent.children.length).toBe(1000);
|
|
483
|
+
expect(removed.length).toBe(0);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("shuffle 1000 nodes preserves identity", () => {
|
|
487
|
+
const parent = document.createElement("div");
|
|
488
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `k${i}`, label: `L${i}` }));
|
|
489
|
+
const spec = {
|
|
490
|
+
key: (i: { id: string; label: string }) => i.id,
|
|
491
|
+
mount: (i: { id: string; label: string }) => {
|
|
492
|
+
const el = document.createElement("span");
|
|
493
|
+
el.textContent = i.label;
|
|
494
|
+
return el;
|
|
495
|
+
},
|
|
496
|
+
update: (el: HTMLElement, i: { id: string; label: string }) => {
|
|
497
|
+
el.textContent = i.label;
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
reconcile(parent, items, spec);
|
|
502
|
+
const refMap = new Map<string, Element>();
|
|
503
|
+
for (const el of Array.from(parent.children)) {
|
|
504
|
+
refMap.set(el.getAttribute("data-reconcile-key")!, el);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Deterministic shuffle (Fisher-Yates with fixed seed)
|
|
508
|
+
const shuffled = [...items];
|
|
509
|
+
let seed = 12345;
|
|
510
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
511
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
|
512
|
+
const j = seed % (i + 1);
|
|
513
|
+
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
reconcile(parent, shuffled, spec);
|
|
517
|
+
expect(parent.children.length).toBe(1000);
|
|
518
|
+
|
|
519
|
+
// Verify identity preservation
|
|
520
|
+
for (const el of Array.from(parent.children)) {
|
|
521
|
+
const key = el.getAttribute("data-reconcile-key")!;
|
|
522
|
+
expect(el).toBe(refMap.get(key));
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// Store: dispose from cleanup does not overflow
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
describe("store: dispose from cleanup", () => {
|
|
532
|
+
it("store effect dispose called from cleanup does not overflow", () => {
|
|
533
|
+
const store = createStore<{ x: number }>();
|
|
534
|
+
store.set("x", 0);
|
|
535
|
+
let disposeRef: (() => void) | null = null;
|
|
536
|
+
disposeRef = store.effect(() => {
|
|
537
|
+
void store.get("x");
|
|
538
|
+
return () => {
|
|
539
|
+
if (disposeRef) {
|
|
540
|
+
disposeRef();
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
// Trigger re-run which calls cleanup which calls dispose
|
|
545
|
+
expect(() => {
|
|
546
|
+
store.set("x", 1);
|
|
547
|
+
}).not.toThrow();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("store effect double dispose is no-op", () => {
|
|
551
|
+
const store = createStore<{ x: number }>();
|
|
552
|
+
store.set("x", 0);
|
|
553
|
+
let cleanupCount = 0;
|
|
554
|
+
const dispose = store.effect(() => {
|
|
555
|
+
void store.get("x");
|
|
556
|
+
return () => {
|
|
557
|
+
cleanupCount++;
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
dispose();
|
|
561
|
+
expect(cleanupCount).toBe(1);
|
|
562
|
+
dispose();
|
|
563
|
+
expect(cleanupCount).toBe(1);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// flushSync edge cases
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
describe("flushSync", () => {
|
|
572
|
+
it("flushSync outside batch drains pending", () => {
|
|
573
|
+
const s = signal(0);
|
|
574
|
+
const spy = vi.fn();
|
|
575
|
+
effect(() => {
|
|
576
|
+
spy(s.value);
|
|
577
|
+
return undefined;
|
|
578
|
+
});
|
|
579
|
+
spy.mockClear();
|
|
580
|
+
// Normally writes flush immediately, but let's verify flushSync is a no-op
|
|
581
|
+
// when nothing is pending
|
|
582
|
+
flushSync();
|
|
583
|
+
expect(spy).not.toHaveBeenCalled();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("flushSync inside batch is a no-op", () => {
|
|
587
|
+
const s = signal(0);
|
|
588
|
+
const spy = vi.fn();
|
|
589
|
+
effect(() => {
|
|
590
|
+
spy(s.value);
|
|
591
|
+
return undefined;
|
|
592
|
+
});
|
|
593
|
+
spy.mockClear();
|
|
594
|
+
batch(() => {
|
|
595
|
+
s.value = 1;
|
|
596
|
+
flushSync(); // should be no-op inside batch
|
|
597
|
+
expect(spy).not.toHaveBeenCalled();
|
|
598
|
+
});
|
|
599
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Computed: dirty flag after disposal of downstream effect
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
describe("computed: lazy after effect disposal", () => {
|
|
608
|
+
it("computed stays fresh after all effect subscribers are disposed", () => {
|
|
609
|
+
const s = signal(1);
|
|
610
|
+
const c = computed(() => s.value * 10);
|
|
611
|
+
const spy = vi.fn();
|
|
612
|
+
const dispose = effect(() => {
|
|
613
|
+
spy(c.value);
|
|
614
|
+
return undefined;
|
|
615
|
+
});
|
|
616
|
+
expect(spy).toHaveBeenCalledWith(10);
|
|
617
|
+
dispose();
|
|
618
|
+
s.value = 5;
|
|
619
|
+
// Computed should still give correct value when read lazily
|
|
620
|
+
expect(c.value).toBe(50);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// Signal: peek() does not track
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
describe("signal peek", () => {
|
|
629
|
+
it("peek inside effect does not create subscription", () => {
|
|
630
|
+
const s = signal(0);
|
|
631
|
+
const spy = vi.fn();
|
|
632
|
+
effect(() => {
|
|
633
|
+
spy(s.peek());
|
|
634
|
+
return undefined;
|
|
635
|
+
});
|
|
636
|
+
expect(spy).toHaveBeenCalledWith(0);
|
|
637
|
+
spy.mockClear();
|
|
638
|
+
s.value = 1;
|
|
639
|
+
expect(spy).not.toHaveBeenCalled();
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Effect cleanup ordering with multiple effects
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
describe("effect cleanup ordering", () => {
|
|
648
|
+
it("cleanup of effect A runs before effect A re-executes, not before B", () => {
|
|
649
|
+
const s = signal(0);
|
|
650
|
+
const order: string[] = [];
|
|
651
|
+
effect(() => {
|
|
652
|
+
order.push(`A:${s.value}`);
|
|
653
|
+
return () => {
|
|
654
|
+
order.push(`cleanup-A:${s.value}`);
|
|
655
|
+
};
|
|
656
|
+
});
|
|
657
|
+
effect(() => {
|
|
658
|
+
order.push(`B:${s.value}`);
|
|
659
|
+
return () => {
|
|
660
|
+
order.push(`cleanup-B:${s.value}`);
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
order.length = 0;
|
|
664
|
+
s.value = 1;
|
|
665
|
+
// Each effect's cleanup runs before its own re-execution
|
|
666
|
+
expect(order).toContain("cleanup-A:1");
|
|
667
|
+
expect(order).toContain("cleanup-B:1");
|
|
668
|
+
expect(order.indexOf("cleanup-A:1")).toBeLessThan(order.indexOf("A:1"));
|
|
669
|
+
expect(order.indexOf("cleanup-B:1")).toBeLessThan(order.indexOf("B:1"));
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Computed: error recovery in effect
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
describe("computed error recovery in effect", () => {
|
|
678
|
+
let prevHandler: EffectErrorHandler;
|
|
679
|
+
const errors: unknown[] = [];
|
|
680
|
+
beforeEach(() => {
|
|
681
|
+
errors.length = 0;
|
|
682
|
+
prevHandler = setEffectErrorHandler((e) => {
|
|
683
|
+
errors.push(e);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
afterEach(() => {
|
|
687
|
+
setEffectErrorHandler(prevHandler);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("effect recovers after computed stops throwing", () => {
|
|
691
|
+
const s = signal(0);
|
|
692
|
+
const c = computed(() => {
|
|
693
|
+
if (s.value === 0) {
|
|
694
|
+
throw new Error("zero");
|
|
695
|
+
}
|
|
696
|
+
return s.value * 2;
|
|
697
|
+
});
|
|
698
|
+
const values: number[] = [];
|
|
699
|
+
effect(() => {
|
|
700
|
+
values.push(c.value);
|
|
701
|
+
return undefined;
|
|
702
|
+
});
|
|
703
|
+
// First run throws
|
|
704
|
+
expect(errors.length).toBe(1);
|
|
705
|
+
expect(values).toEqual([]);
|
|
706
|
+
|
|
707
|
+
// Fix the signal
|
|
708
|
+
s.value = 5;
|
|
709
|
+
expect(values).toEqual([10]);
|
|
710
|
+
});
|
|
711
|
+
});
|