@cplieger/reactive 1.0.1 → 1.0.3
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 +6 -3
- 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/redteam7.test.ts
DELETED
|
@@ -1,658 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
// RED-TEAM round-7 — final convergence verification
|
|
3
|
-
// Per-callback-site throw-safety + pathological graphs (diamonds, chains, fan-in/out)
|
|
4
|
-
import { describe, it, expect, vi } from "vitest";
|
|
5
|
-
import { signal, effect, batch, computed, setEffectErrorHandler, flushSync } from "./index.js";
|
|
6
|
-
import { createStore } from "./store.js";
|
|
7
|
-
import { reconcile, KEY_ATTR } from "./reconcile.js";
|
|
8
|
-
|
|
9
|
-
function withHandler(fn: (errors: unknown[]) => void): void {
|
|
10
|
-
const errors: unknown[] = [];
|
|
11
|
-
const prev = setEffectErrorHandler((e) => errors.push(e));
|
|
12
|
-
try {
|
|
13
|
-
fn(errors);
|
|
14
|
-
} finally {
|
|
15
|
-
setEffectErrorHandler(prev);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// ===========================================================================
|
|
20
|
-
// CALLBACK SITE 1: effect body throw in pathological graphs
|
|
21
|
-
// ===========================================================================
|
|
22
|
-
describe("R7: effect body throw — pathological graphs", () => {
|
|
23
|
-
it("diamond graph: root → L,R → join; L throws — join still subscribes", () => {
|
|
24
|
-
withHandler((errors) => {
|
|
25
|
-
const root = signal(0);
|
|
26
|
-
const left = computed(() => root.value + 1);
|
|
27
|
-
const right = computed(() => root.value + 2);
|
|
28
|
-
const log: string[] = [];
|
|
29
|
-
// L effect throws
|
|
30
|
-
effect(() => {
|
|
31
|
-
if (left.value > 1) {
|
|
32
|
-
throw new Error("L-boom");
|
|
33
|
-
}
|
|
34
|
-
return undefined;
|
|
35
|
-
});
|
|
36
|
-
// Join effect reads both
|
|
37
|
-
effect(() => {
|
|
38
|
-
log.push(`${left.value}+${right.value}`);
|
|
39
|
-
return undefined;
|
|
40
|
-
});
|
|
41
|
-
log.length = 0;
|
|
42
|
-
errors.length = 0;
|
|
43
|
-
root.value = 1;
|
|
44
|
-
expect(errors.length).toBe(1);
|
|
45
|
-
expect(log).toContain("2+3");
|
|
46
|
-
// Verify join still subscribes after L threw
|
|
47
|
-
log.length = 0;
|
|
48
|
-
root.value = 2;
|
|
49
|
-
expect(log).toContain("3+4");
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("fan-out: one source → 20 effects, all throw — no skipped, all re-subscribe", () => {
|
|
54
|
-
withHandler((errors) => {
|
|
55
|
-
const s = signal(0);
|
|
56
|
-
const spies = Array.from({ length: 20 }, () => vi.fn());
|
|
57
|
-
spies.forEach((spy, i) => {
|
|
58
|
-
effect(() => {
|
|
59
|
-
spy(s.value);
|
|
60
|
-
if (s.value > 0) {
|
|
61
|
-
throw new Error(`fan-${i}`);
|
|
62
|
-
}
|
|
63
|
-
return undefined;
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
spies.forEach((spy) => spy.mockClear());
|
|
67
|
-
errors.length = 0;
|
|
68
|
-
s.value = 1;
|
|
69
|
-
expect(errors.length).toBe(20);
|
|
70
|
-
spies.forEach((spy) => expect(spy).toHaveBeenCalledWith(1));
|
|
71
|
-
// All re-subscribe
|
|
72
|
-
spies.forEach((spy) => spy.mockClear());
|
|
73
|
-
errors.length = 0;
|
|
74
|
-
s.value = 2;
|
|
75
|
-
expect(errors.length).toBe(20);
|
|
76
|
-
spies.forEach((spy) => expect(spy).toHaveBeenCalledWith(2));
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("chain: s → c1 → c2 → c3 → effect; c2 throws — effect still works after recovery", () => {
|
|
81
|
-
withHandler(() => {
|
|
82
|
-
const s = signal(0);
|
|
83
|
-
const c1 = computed(() => s.value * 2);
|
|
84
|
-
let throwInC2 = false;
|
|
85
|
-
const c2 = computed(() => {
|
|
86
|
-
if (throwInC2) {
|
|
87
|
-
throw new Error("c2-chain");
|
|
88
|
-
}
|
|
89
|
-
return c1.value + 1;
|
|
90
|
-
});
|
|
91
|
-
const c3 = computed(() => c2.value + 10);
|
|
92
|
-
const log: string[] = [];
|
|
93
|
-
effect(() => {
|
|
94
|
-
try {
|
|
95
|
-
log.push(`v=${c3.value}`);
|
|
96
|
-
} catch {
|
|
97
|
-
log.push("err");
|
|
98
|
-
}
|
|
99
|
-
return undefined;
|
|
100
|
-
});
|
|
101
|
-
log.length = 0;
|
|
102
|
-
throwInC2 = true;
|
|
103
|
-
s.value = 1;
|
|
104
|
-
expect(log).toContain("err");
|
|
105
|
-
// Recovery
|
|
106
|
-
log.length = 0;
|
|
107
|
-
throwInC2 = false;
|
|
108
|
-
s.value = 2;
|
|
109
|
-
expect(log).toContain("v=15"); // 2*2+1+10=15
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("fan-in: 5 signals → 1 computed → effect; throw in effect body", () => {
|
|
114
|
-
withHandler((errors) => {
|
|
115
|
-
const sigs = Array.from({ length: 5 }, (_, i) => signal(i));
|
|
116
|
-
const sum = computed(() => sigs.reduce((a, s) => a + s.value, 0));
|
|
117
|
-
const spy = vi.fn();
|
|
118
|
-
effect(() => {
|
|
119
|
-
spy(sum.value);
|
|
120
|
-
if (sum.value > 10) {
|
|
121
|
-
throw new Error("fan-in-boom");
|
|
122
|
-
}
|
|
123
|
-
return undefined;
|
|
124
|
-
});
|
|
125
|
-
spy.mockClear();
|
|
126
|
-
errors.length = 0;
|
|
127
|
-
batch(() => {
|
|
128
|
-
sigs.forEach((s) => {
|
|
129
|
-
s.value = 10;
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
expect(errors.length).toBe(1);
|
|
133
|
-
expect(spy).toHaveBeenCalledWith(50);
|
|
134
|
-
// Still subscribes
|
|
135
|
-
spy.mockClear();
|
|
136
|
-
errors.length = 0;
|
|
137
|
-
sigs[0]!.value = 0;
|
|
138
|
-
expect(spy).toHaveBeenCalledWith(40);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// ===========================================================================
|
|
144
|
-
// CALLBACK SITE 2: effect cleanup throw — pathological cases
|
|
145
|
-
// ===========================================================================
|
|
146
|
-
describe("R7: effect cleanup throw — pathological", () => {
|
|
147
|
-
it("cleanup throws then effect body throws — both caught, effect re-subscribes", () => {
|
|
148
|
-
withHandler((errors) => {
|
|
149
|
-
const s = signal(0);
|
|
150
|
-
let throwCount = 0;
|
|
151
|
-
effect(() => {
|
|
152
|
-
void s.value;
|
|
153
|
-
throwCount++;
|
|
154
|
-
if (throwCount > 1) {
|
|
155
|
-
throw new Error("body-throw");
|
|
156
|
-
}
|
|
157
|
-
return () => {
|
|
158
|
-
throw new Error("cleanup-throw");
|
|
159
|
-
};
|
|
160
|
-
});
|
|
161
|
-
errors.length = 0;
|
|
162
|
-
s.value = 1; // triggers cleanup (throws) then body (throws)
|
|
163
|
-
expect(errors.length).toBe(2);
|
|
164
|
-
// Still subscribed for next update
|
|
165
|
-
errors.length = 0;
|
|
166
|
-
throwCount = 0; // reset to not throw
|
|
167
|
-
s.value = 2;
|
|
168
|
-
// Effect ran (throwCount incremented)
|
|
169
|
-
expect(throwCount).toBe(1);
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("cleanup throws during dispose — no zombie", () => {
|
|
174
|
-
withHandler((errors) => {
|
|
175
|
-
const s = signal(0);
|
|
176
|
-
const spy = vi.fn();
|
|
177
|
-
const dispose = effect(() => {
|
|
178
|
-
spy(s.value);
|
|
179
|
-
return () => {
|
|
180
|
-
throw new Error("dispose-cleanup");
|
|
181
|
-
};
|
|
182
|
-
});
|
|
183
|
-
spy.mockClear();
|
|
184
|
-
errors.length = 0;
|
|
185
|
-
dispose();
|
|
186
|
-
expect(errors.length).toBe(1);
|
|
187
|
-
spy.mockClear();
|
|
188
|
-
s.value = 1;
|
|
189
|
-
expect(spy).not.toHaveBeenCalled(); // no zombie
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// ===========================================================================
|
|
195
|
-
// CALLBACK SITE 3: computed fn throw — pathological
|
|
196
|
-
// ===========================================================================
|
|
197
|
-
describe("R7: computed fn throw — pathological graphs", () => {
|
|
198
|
-
it("diamond: root → cL(throws), cR → join-computed → effect", () => {
|
|
199
|
-
withHandler(() => {
|
|
200
|
-
const root = signal(0);
|
|
201
|
-
const cL = computed(() => {
|
|
202
|
-
if (root.value > 0) {
|
|
203
|
-
throw new Error("cL");
|
|
204
|
-
}
|
|
205
|
-
return root.value;
|
|
206
|
-
});
|
|
207
|
-
const cR = computed(() => root.value * 10);
|
|
208
|
-
const join = computed(() => {
|
|
209
|
-
let l: number;
|
|
210
|
-
try {
|
|
211
|
-
l = cL.value;
|
|
212
|
-
} catch {
|
|
213
|
-
l = -1;
|
|
214
|
-
}
|
|
215
|
-
return l + cR.value;
|
|
216
|
-
});
|
|
217
|
-
const log: number[] = [];
|
|
218
|
-
effect(() => {
|
|
219
|
-
log.push(join.value);
|
|
220
|
-
return undefined;
|
|
221
|
-
});
|
|
222
|
-
log.length = 0;
|
|
223
|
-
root.value = 1;
|
|
224
|
-
expect(log).toContain(-1 + 10); // -1 + 10 = 9
|
|
225
|
-
// cL recovers
|
|
226
|
-
log.length = 0;
|
|
227
|
-
root.value = -1;
|
|
228
|
-
expect(log).toContain(-1 + -10); // -1 + -10 = -11
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("computed throws mid-tracking — partial deps preserved for retry (no zombie)", () => {
|
|
233
|
-
withHandler(() => {
|
|
234
|
-
const a = signal(1);
|
|
235
|
-
const b = signal(2);
|
|
236
|
-
let shouldThrow = false;
|
|
237
|
-
const c = computed(() => {
|
|
238
|
-
const av = a.value; // subscribes to a
|
|
239
|
-
if (shouldThrow) {
|
|
240
|
-
throw new Error("mid-track");
|
|
241
|
-
}
|
|
242
|
-
return av + b.value; // subscribes to b only if no throw
|
|
243
|
-
});
|
|
244
|
-
const spy = vi.fn();
|
|
245
|
-
effect(() => {
|
|
246
|
-
try {
|
|
247
|
-
spy(c.value);
|
|
248
|
-
} catch {
|
|
249
|
-
spy("err");
|
|
250
|
-
}
|
|
251
|
-
return undefined;
|
|
252
|
-
});
|
|
253
|
-
expect(spy).toHaveBeenCalledWith(3);
|
|
254
|
-
shouldThrow = true;
|
|
255
|
-
spy.mockClear();
|
|
256
|
-
a.value = 10; // triggers recompute, throws mid-track
|
|
257
|
-
expect(spy).toHaveBeenCalledWith("err");
|
|
258
|
-
// The computed restored old deps (a,b) — changing b triggers recompute.
|
|
259
|
-
// But since the computed still errors with same "shape" (hasError=true, val unchanged),
|
|
260
|
-
// the equality dedup correctly skips re-notification. This is correct behavior.
|
|
261
|
-
// The key invariant: when we RECOVER, changing b triggers the recompute.
|
|
262
|
-
shouldThrow = false;
|
|
263
|
-
spy.mockClear();
|
|
264
|
-
b.value = 20; // triggers recompute because b dep was preserved
|
|
265
|
-
// Now it succeeds: a=10, b=20 → 30
|
|
266
|
-
expect(spy).toHaveBeenCalledWith(30);
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// ===========================================================================
|
|
272
|
-
// CALLBACK SITE 4: computed equals throw
|
|
273
|
-
// ===========================================================================
|
|
274
|
-
describe("R7: computed equals comparator throw", () => {
|
|
275
|
-
it("equals throws — treated as changed, downstream notified, no corruption", () => {
|
|
276
|
-
withHandler((_errors) => {
|
|
277
|
-
const s = signal(0);
|
|
278
|
-
let eqThrow = false;
|
|
279
|
-
const c = computed(() => s.value * 2, {
|
|
280
|
-
equals: () => {
|
|
281
|
-
if (eqThrow) {
|
|
282
|
-
throw new Error("eq-boom");
|
|
283
|
-
}
|
|
284
|
-
return false;
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
const spy = vi.fn();
|
|
288
|
-
effect(() => {
|
|
289
|
-
spy(c.value);
|
|
290
|
-
return undefined;
|
|
291
|
-
});
|
|
292
|
-
spy.mockClear();
|
|
293
|
-
eqThrow = true;
|
|
294
|
-
s.value = 1;
|
|
295
|
-
// The equals throw in computed.sub.execute should be caught (try/catch around eq)
|
|
296
|
-
// and treated as changed=true, so downstream gets notified
|
|
297
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
298
|
-
// System still works
|
|
299
|
-
eqThrow = false;
|
|
300
|
-
spy.mockClear();
|
|
301
|
-
s.value = 2;
|
|
302
|
-
expect(spy).toHaveBeenCalledWith(4);
|
|
303
|
-
// No errors reported to handler (eq throw is internal to computed machinery)
|
|
304
|
-
// Actually check: computed's execute() catches eq throw internally
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
// ===========================================================================
|
|
310
|
-
// CALLBACK SITE 5: signal equals throw
|
|
311
|
-
// ===========================================================================
|
|
312
|
-
describe("R7: signal equals throw", () => {
|
|
313
|
-
it("equals throws inside deeply nested batch — batchDepth restored", () => {
|
|
314
|
-
const s = signal(0, {
|
|
315
|
-
equals: (_a, b) => {
|
|
316
|
-
if (b === 100) {
|
|
317
|
-
throw new Error("deep-eq");
|
|
318
|
-
}
|
|
319
|
-
return _a === b;
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
const spy = vi.fn();
|
|
323
|
-
const prev = setEffectErrorHandler(() => {
|
|
324
|
-
/* swallow */
|
|
325
|
-
});
|
|
326
|
-
effect(() => {
|
|
327
|
-
spy(s.value);
|
|
328
|
-
return undefined;
|
|
329
|
-
});
|
|
330
|
-
spy.mockClear();
|
|
331
|
-
expect(() => {
|
|
332
|
-
batch(() =>
|
|
333
|
-
batch(() =>
|
|
334
|
-
batch(() => {
|
|
335
|
-
s.value = 100;
|
|
336
|
-
}),
|
|
337
|
-
),
|
|
338
|
-
);
|
|
339
|
-
}).toThrow("deep-eq");
|
|
340
|
-
// batchDepth must be 0 — next write must flush immediately
|
|
341
|
-
spy.mockClear();
|
|
342
|
-
s.value = 7;
|
|
343
|
-
expect(spy).toHaveBeenCalledWith(7);
|
|
344
|
-
setEffectErrorHandler(prev);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("equals throws — does not corrupt signal value or subscriber set", () => {
|
|
348
|
-
const s = signal(42, {
|
|
349
|
-
equals: (_a, b) => {
|
|
350
|
-
if (b === 0) {
|
|
351
|
-
throw new Error("zero-eq");
|
|
352
|
-
}
|
|
353
|
-
return _a === b;
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
const spy = vi.fn();
|
|
357
|
-
const prev = setEffectErrorHandler(() => {
|
|
358
|
-
/* swallow */
|
|
359
|
-
});
|
|
360
|
-
effect(() => {
|
|
361
|
-
spy(s.value);
|
|
362
|
-
return undefined;
|
|
363
|
-
});
|
|
364
|
-
spy.mockClear();
|
|
365
|
-
expect(() => {
|
|
366
|
-
s.value = 0;
|
|
367
|
-
}).toThrow("zero-eq");
|
|
368
|
-
expect(s.peek()).toBe(42); // value unchanged
|
|
369
|
-
spy.mockClear();
|
|
370
|
-
s.value = 99;
|
|
371
|
-
expect(spy).toHaveBeenCalledWith(99);
|
|
372
|
-
expect(s.peek()).toBe(99);
|
|
373
|
-
setEffectErrorHandler(prev);
|
|
374
|
-
});
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
// ===========================================================================
|
|
378
|
-
// CALLBACK SITE 6: store subscriber throw
|
|
379
|
-
// ===========================================================================
|
|
380
|
-
describe("R7: store subscriber throw", () => {
|
|
381
|
-
it("subscriber throws — other subscribers still notified", () => {
|
|
382
|
-
const store = createStore<{ x: number }>();
|
|
383
|
-
store.set("x", 0);
|
|
384
|
-
const spy1 = vi.fn();
|
|
385
|
-
const spy2 = vi.fn();
|
|
386
|
-
store.subscribe("x", () => {
|
|
387
|
-
throw new Error("sub-throw");
|
|
388
|
-
});
|
|
389
|
-
store.subscribe("x", spy1);
|
|
390
|
-
store.subscribe("x", spy2);
|
|
391
|
-
store.set("x", 5);
|
|
392
|
-
expect(spy1).toHaveBeenCalledWith(5);
|
|
393
|
-
expect(spy2).toHaveBeenCalledWith(5);
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it("store batch — subscriber throw doesn't corrupt batchDepth", () => {
|
|
397
|
-
const store = createStore<{ a: number; b: number }>();
|
|
398
|
-
store.set("a", 0);
|
|
399
|
-
store.set("b", 0);
|
|
400
|
-
store.subscribe("a", () => {
|
|
401
|
-
throw new Error("a-sub");
|
|
402
|
-
});
|
|
403
|
-
const spy = vi.fn();
|
|
404
|
-
store.subscribe("b", spy);
|
|
405
|
-
store.batch(() => {
|
|
406
|
-
store.set("a", 1);
|
|
407
|
-
store.set("b", 2);
|
|
408
|
-
});
|
|
409
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
410
|
-
// Verify non-batch still works after
|
|
411
|
-
spy.mockClear();
|
|
412
|
-
store.set("b", 3);
|
|
413
|
-
expect(spy).toHaveBeenCalledWith(3);
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
// ===========================================================================
|
|
418
|
-
// CALLBACK SITE 7: reconcile mount/update/onRemove/key throw
|
|
419
|
-
// (These don't touch reactive state, just verify no infinite loop/hang)
|
|
420
|
-
// ===========================================================================
|
|
421
|
-
describe("R7: reconcile callbacks throw — no hang", () => {
|
|
422
|
-
it("key throws — propagates, no reactive corruption", () => {
|
|
423
|
-
const parent = document.createElement("div");
|
|
424
|
-
expect(() => {
|
|
425
|
-
reconcile(parent, [{ id: 1 }], {
|
|
426
|
-
key: () => {
|
|
427
|
-
throw new Error("key-throw");
|
|
428
|
-
},
|
|
429
|
-
mount: (item) => {
|
|
430
|
-
const el = document.createElement("div");
|
|
431
|
-
el.textContent = String(item);
|
|
432
|
-
return el;
|
|
433
|
-
},
|
|
434
|
-
});
|
|
435
|
-
}).toThrow("key-throw");
|
|
436
|
-
// Reactive system still works after
|
|
437
|
-
const s = signal(0);
|
|
438
|
-
const spy = vi.fn();
|
|
439
|
-
const prev = setEffectErrorHandler(() => {
|
|
440
|
-
/* swallow */
|
|
441
|
-
});
|
|
442
|
-
effect(() => {
|
|
443
|
-
spy(s.value);
|
|
444
|
-
return undefined;
|
|
445
|
-
});
|
|
446
|
-
spy.mockClear();
|
|
447
|
-
s.value = 1;
|
|
448
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
449
|
-
setEffectErrorHandler(prev);
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it("mount throws — propagates, no reactive corruption", () => {
|
|
453
|
-
const parent = document.createElement("div");
|
|
454
|
-
expect(() => {
|
|
455
|
-
reconcile(parent, [{ id: 1 }], {
|
|
456
|
-
key: (item) => String(item.id),
|
|
457
|
-
mount: () => {
|
|
458
|
-
throw new Error("mount-throw");
|
|
459
|
-
},
|
|
460
|
-
});
|
|
461
|
-
}).toThrow("mount-throw");
|
|
462
|
-
const s = signal(0);
|
|
463
|
-
const spy = vi.fn();
|
|
464
|
-
const prev = setEffectErrorHandler(() => {
|
|
465
|
-
/* swallow */
|
|
466
|
-
});
|
|
467
|
-
effect(() => {
|
|
468
|
-
spy(s.value);
|
|
469
|
-
return undefined;
|
|
470
|
-
});
|
|
471
|
-
spy.mockClear();
|
|
472
|
-
s.value = 1;
|
|
473
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
474
|
-
setEffectErrorHandler(prev);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
it("update throws — propagates, no reactive corruption", () => {
|
|
478
|
-
const parent = document.createElement("div");
|
|
479
|
-
reconcile(parent, [{ id: 1, v: "a" }], {
|
|
480
|
-
key: (item) => String(item.id),
|
|
481
|
-
mount: (item) => {
|
|
482
|
-
const el = document.createElement("div");
|
|
483
|
-
el.setAttribute(KEY_ATTR, String(item.id));
|
|
484
|
-
return el;
|
|
485
|
-
},
|
|
486
|
-
update: () => {
|
|
487
|
-
throw new Error("update-throw");
|
|
488
|
-
},
|
|
489
|
-
});
|
|
490
|
-
// Now update
|
|
491
|
-
expect(() => {
|
|
492
|
-
reconcile(parent, [{ id: 1, v: "b" }], {
|
|
493
|
-
key: (item) => String(item.id),
|
|
494
|
-
mount: (item) => {
|
|
495
|
-
const el = document.createElement("div");
|
|
496
|
-
el.setAttribute(KEY_ATTR, String(item.id));
|
|
497
|
-
return el;
|
|
498
|
-
},
|
|
499
|
-
update: () => {
|
|
500
|
-
throw new Error("update-throw");
|
|
501
|
-
},
|
|
502
|
-
});
|
|
503
|
-
}).toThrow("update-throw");
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it("onRemove throws — propagates, no reactive corruption", () => {
|
|
507
|
-
const parent = document.createElement("div");
|
|
508
|
-
const spec = {
|
|
509
|
-
key: (item: { id: number }) => String(item.id),
|
|
510
|
-
mount: (item: { id: number }) => {
|
|
511
|
-
const el = document.createElement("div");
|
|
512
|
-
el.setAttribute(KEY_ATTR, String(item.id));
|
|
513
|
-
return el;
|
|
514
|
-
},
|
|
515
|
-
onRemove: () => {
|
|
516
|
-
throw new Error("onRemove-throw");
|
|
517
|
-
},
|
|
518
|
-
};
|
|
519
|
-
reconcile(parent, [{ id: 1 }, { id: 2 }], spec);
|
|
520
|
-
expect(() => {
|
|
521
|
-
reconcile(parent, [{ id: 1 }], spec); // removes id=2, onRemove throws
|
|
522
|
-
}).toThrow("onRemove-throw");
|
|
523
|
-
});
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
// ===========================================================================
|
|
527
|
-
// INVARIANT CHECKS: flushing, batchDepth, no infinite loop
|
|
528
|
-
// ===========================================================================
|
|
529
|
-
describe("R7: global invariants after throw storms", () => {
|
|
530
|
-
it("100 alternating throw/success cycles — system remains stable", () => {
|
|
531
|
-
withHandler(() => {
|
|
532
|
-
const s = signal(0);
|
|
533
|
-
let shouldThrow = true;
|
|
534
|
-
effect(() => {
|
|
535
|
-
void s.value;
|
|
536
|
-
if (shouldThrow) {
|
|
537
|
-
throw new Error("storm");
|
|
538
|
-
}
|
|
539
|
-
return undefined;
|
|
540
|
-
});
|
|
541
|
-
for (let i = 1; i <= 100; i++) {
|
|
542
|
-
shouldThrow = i % 2 === 0;
|
|
543
|
-
s.value = i;
|
|
544
|
-
}
|
|
545
|
-
// Final state — effect should have run (shouldThrow=false for odd=100? no, 100%2=0 so throws)
|
|
546
|
-
shouldThrow = false;
|
|
547
|
-
const spy = vi.fn();
|
|
548
|
-
effect(() => {
|
|
549
|
-
spy(s.value);
|
|
550
|
-
return undefined;
|
|
551
|
-
});
|
|
552
|
-
spy.mockClear();
|
|
553
|
-
s.value = 999;
|
|
554
|
-
expect(spy).toHaveBeenCalledWith(999);
|
|
555
|
-
});
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
it("flushSync after throw storm — works (flushing not stuck)", () => {
|
|
559
|
-
withHandler(() => {
|
|
560
|
-
const s = signal(0);
|
|
561
|
-
effect(() => {
|
|
562
|
-
if (s.value > 0) {
|
|
563
|
-
throw new Error("fs");
|
|
564
|
-
}
|
|
565
|
-
return undefined;
|
|
566
|
-
});
|
|
567
|
-
s.value = 1;
|
|
568
|
-
// If flushing were stuck, this would be a no-op
|
|
569
|
-
const s2 = signal(0);
|
|
570
|
-
const spy = vi.fn();
|
|
571
|
-
effect(() => {
|
|
572
|
-
spy(s2.value);
|
|
573
|
-
return undefined;
|
|
574
|
-
});
|
|
575
|
-
spy.mockClear();
|
|
576
|
-
batch(() => {
|
|
577
|
-
s2.value = 42;
|
|
578
|
-
});
|
|
579
|
-
flushSync();
|
|
580
|
-
expect(spy).toHaveBeenCalledWith(42);
|
|
581
|
-
});
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
it("no infinite loop: effect throws and re-dispatches to itself (writes in error path)", () => {
|
|
585
|
-
// If effect throws, it's caught. If during that the error handler writes to
|
|
586
|
-
// the same signal, the effect is re-queued. Could this loop forever?
|
|
587
|
-
// Answer: no, because drainPending processes the current batch, and new
|
|
588
|
-
// pending items are processed in the next while-loop iteration.
|
|
589
|
-
// But let's verify it terminates.
|
|
590
|
-
const s = signal(0);
|
|
591
|
-
let writeCount = 0;
|
|
592
|
-
const prev = setEffectErrorHandler(() => {
|
|
593
|
-
// Write back to the signal — but only a few times
|
|
594
|
-
if (writeCount < 5) {
|
|
595
|
-
writeCount++;
|
|
596
|
-
s.value = s.peek() + 1;
|
|
597
|
-
}
|
|
598
|
-
});
|
|
599
|
-
effect(() => {
|
|
600
|
-
if (s.value > 0 && s.value < 10) {
|
|
601
|
-
throw new Error("loop-test");
|
|
602
|
-
}
|
|
603
|
-
return undefined;
|
|
604
|
-
});
|
|
605
|
-
s.value = 1;
|
|
606
|
-
// Should terminate. writeCount should be 5 (handler stops writing after 5)
|
|
607
|
-
expect(writeCount).toBe(5);
|
|
608
|
-
expect(s.peek()).toBe(6); // 1 + 5 increments
|
|
609
|
-
setEffectErrorHandler(prev);
|
|
610
|
-
});
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
// ===========================================================================
|
|
614
|
-
// CROSS-CUTTING: computed in effect that throws during batch flush
|
|
615
|
-
// ===========================================================================
|
|
616
|
-
describe("R7: computed+effect interaction during batch flush throw", () => {
|
|
617
|
-
it("batch with 3 computeds, middle throws — first and last still propagate", () => {
|
|
618
|
-
withHandler(() => {
|
|
619
|
-
const s = signal(0);
|
|
620
|
-
const c1 = computed(() => s.value + 1);
|
|
621
|
-
const c2 = computed(() => {
|
|
622
|
-
if (s.value > 0) {
|
|
623
|
-
throw new Error("c2-batch");
|
|
624
|
-
}
|
|
625
|
-
return s.value + 2;
|
|
626
|
-
});
|
|
627
|
-
const c3 = computed(() => s.value + 3);
|
|
628
|
-
const log1: number[] = [];
|
|
629
|
-
const log2: string[] = [];
|
|
630
|
-
const log3: number[] = [];
|
|
631
|
-
effect(() => {
|
|
632
|
-
log1.push(c1.value);
|
|
633
|
-
return undefined;
|
|
634
|
-
});
|
|
635
|
-
effect(() => {
|
|
636
|
-
try {
|
|
637
|
-
log2.push(`v=${c2.value}`);
|
|
638
|
-
} catch {
|
|
639
|
-
log2.push("err");
|
|
640
|
-
}
|
|
641
|
-
return undefined;
|
|
642
|
-
});
|
|
643
|
-
effect(() => {
|
|
644
|
-
log3.push(c3.value);
|
|
645
|
-
return undefined;
|
|
646
|
-
});
|
|
647
|
-
log1.length = 0;
|
|
648
|
-
log2.length = 0;
|
|
649
|
-
log3.length = 0;
|
|
650
|
-
batch(() => {
|
|
651
|
-
s.value = 5;
|
|
652
|
-
});
|
|
653
|
-
expect(log1).toContain(6);
|
|
654
|
-
expect(log2).toContain("err");
|
|
655
|
-
expect(log3).toContain(8);
|
|
656
|
-
});
|
|
657
|
-
});
|
|
658
|
-
});
|