@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/redteam6.test.ts
DELETED
|
@@ -1,650 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
// RED-TEAM round-6 — convergence verification
|
|
3
|
-
// Attacks: re-entrant writes during throw, computed recompute exception during
|
|
4
|
-
// batch flush with downstream effects, multiple independent throwers in one
|
|
5
|
-
// flush, throw then immediate new dispatch, equals throwing in signal setter,
|
|
6
|
-
// dispose() in error handler, diamond with dual throwing computeds.
|
|
7
|
-
import { describe, it, expect, vi } from "vitest";
|
|
8
|
-
import { signal, effect, batch, computed, setEffectErrorHandler } from "./index.js";
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Helpers
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
function withHandler(fn: (errors: unknown[]) => void): void {
|
|
14
|
-
const errors: unknown[] = [];
|
|
15
|
-
const prev = setEffectErrorHandler((e) => {
|
|
16
|
-
errors.push(e);
|
|
17
|
-
});
|
|
18
|
-
try {
|
|
19
|
-
fn(errors);
|
|
20
|
-
} finally {
|
|
21
|
-
setEffectErrorHandler(prev);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// 1. Re-entrant signal write inside effectErrorHandler during flush
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
describe("re-entrant write inside effectErrorHandler during flush", () => {
|
|
29
|
-
it("handler writes to another signal — downstream effect runs after flush", () => {
|
|
30
|
-
const s = signal(0);
|
|
31
|
-
const s2 = signal("init");
|
|
32
|
-
const prev = setEffectErrorHandler(() => {
|
|
33
|
-
s2.value = "from-handler";
|
|
34
|
-
});
|
|
35
|
-
const log: string[] = [];
|
|
36
|
-
effect(() => {
|
|
37
|
-
log.push(`s2=${s2.value}`);
|
|
38
|
-
return undefined;
|
|
39
|
-
});
|
|
40
|
-
effect(() => {
|
|
41
|
-
if (s.value > 0) {
|
|
42
|
-
throw new Error("boom");
|
|
43
|
-
}
|
|
44
|
-
return undefined;
|
|
45
|
-
});
|
|
46
|
-
log.length = 0;
|
|
47
|
-
s.value = 1;
|
|
48
|
-
setEffectErrorHandler(prev);
|
|
49
|
-
expect(log).toContain("s2=from-handler");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("handler writes during batch — effects flush at batch end", () => {
|
|
53
|
-
const s = signal(0);
|
|
54
|
-
const s2 = signal(0);
|
|
55
|
-
const prev = setEffectErrorHandler(() => {
|
|
56
|
-
s2.value = 42;
|
|
57
|
-
});
|
|
58
|
-
const spy = vi.fn();
|
|
59
|
-
effect(() => {
|
|
60
|
-
spy(s2.value);
|
|
61
|
-
return undefined;
|
|
62
|
-
});
|
|
63
|
-
effect(() => {
|
|
64
|
-
if (s.value > 0) {
|
|
65
|
-
throw new Error("x");
|
|
66
|
-
}
|
|
67
|
-
return undefined;
|
|
68
|
-
});
|
|
69
|
-
spy.mockClear();
|
|
70
|
-
batch(() => {
|
|
71
|
-
s.value = 1;
|
|
72
|
-
});
|
|
73
|
-
setEffectErrorHandler(prev);
|
|
74
|
-
expect(spy).toHaveBeenCalledWith(42);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// 2. Exception in computed recompute during batch flush with downstream effects
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
describe("computed recompute throws during batch flush with downstream", () => {
|
|
82
|
-
it("throwing computed doesn't prevent downstream effects from running", () => {
|
|
83
|
-
withHandler((_errors) => {
|
|
84
|
-
const s = signal(0);
|
|
85
|
-
const bad = computed(() => {
|
|
86
|
-
if (s.value > 0) {
|
|
87
|
-
throw new Error("computed-recompute-boom");
|
|
88
|
-
}
|
|
89
|
-
return s.value;
|
|
90
|
-
});
|
|
91
|
-
const good = computed(() => s.value * 10);
|
|
92
|
-
const logBad: string[] = [];
|
|
93
|
-
const logGood: number[] = [];
|
|
94
|
-
effect(() => {
|
|
95
|
-
try {
|
|
96
|
-
logBad.push(`v=${bad.value}`);
|
|
97
|
-
} catch (_e) {
|
|
98
|
-
logBad.push("err");
|
|
99
|
-
}
|
|
100
|
-
return undefined;
|
|
101
|
-
});
|
|
102
|
-
effect(() => {
|
|
103
|
-
logGood.push(good.value);
|
|
104
|
-
return undefined;
|
|
105
|
-
});
|
|
106
|
-
logBad.length = 0;
|
|
107
|
-
logGood.length = 0;
|
|
108
|
-
batch(() => {
|
|
109
|
-
s.value = 5;
|
|
110
|
-
});
|
|
111
|
-
expect(logBad).toContain("err");
|
|
112
|
-
expect(logGood).toContain(50);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("computed throws then recovers — effect tracks again", () => {
|
|
117
|
-
const prev = setEffectErrorHandler(() => {
|
|
118
|
-
// swallow
|
|
119
|
-
});
|
|
120
|
-
const s = signal(0);
|
|
121
|
-
let shouldThrow = false;
|
|
122
|
-
const c = computed(() => {
|
|
123
|
-
if (shouldThrow) {
|
|
124
|
-
throw new Error("temp-fail");
|
|
125
|
-
}
|
|
126
|
-
return s.value * 2;
|
|
127
|
-
});
|
|
128
|
-
const log: string[] = [];
|
|
129
|
-
effect(() => {
|
|
130
|
-
try {
|
|
131
|
-
log.push(`v=${c.value}`);
|
|
132
|
-
} catch (_e) {
|
|
133
|
-
log.push("err");
|
|
134
|
-
}
|
|
135
|
-
return undefined;
|
|
136
|
-
});
|
|
137
|
-
shouldThrow = true;
|
|
138
|
-
s.value = 1;
|
|
139
|
-
shouldThrow = false;
|
|
140
|
-
s.value = 2;
|
|
141
|
-
setEffectErrorHandler(prev);
|
|
142
|
-
expect(log).toContain("v=4"); // recovered
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
// 3. Multiple independent throwers in one flush
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
describe("multiple independent throwers in one flush", () => {
|
|
150
|
-
it("10 effects all throw — all are caught, system is stable after", () => {
|
|
151
|
-
withHandler((errors) => {
|
|
152
|
-
const s = signal(0);
|
|
153
|
-
const spies: ReturnType<typeof vi.fn>[] = [];
|
|
154
|
-
for (let i = 0; i < 10; i++) {
|
|
155
|
-
const spy = vi.fn();
|
|
156
|
-
spies.push(spy);
|
|
157
|
-
effect(() => {
|
|
158
|
-
spy(s.value);
|
|
159
|
-
if (s.value > 0) {
|
|
160
|
-
throw new Error(`multi-throw-${i}`);
|
|
161
|
-
}
|
|
162
|
-
return undefined;
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
for (const spy of spies) {
|
|
166
|
-
spy.mockClear();
|
|
167
|
-
}
|
|
168
|
-
errors.length = 0;
|
|
169
|
-
s.value = 1;
|
|
170
|
-
expect(errors.length).toBe(10);
|
|
171
|
-
for (const spy of spies) {
|
|
172
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
173
|
-
}
|
|
174
|
-
// System stable: all re-subscribe and can run again
|
|
175
|
-
for (const spy of spies) {
|
|
176
|
-
spy.mockClear();
|
|
177
|
-
}
|
|
178
|
-
errors.length = 0;
|
|
179
|
-
s.value = 2;
|
|
180
|
-
expect(errors.length).toBe(10);
|
|
181
|
-
for (const spy of spies) {
|
|
182
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("mix: some throw, some don't — non-throwers run correctly", () => {
|
|
188
|
-
withHandler((errors) => {
|
|
189
|
-
const s = signal(0);
|
|
190
|
-
const goodSpy = vi.fn();
|
|
191
|
-
const badSpy = vi.fn();
|
|
192
|
-
effect(() => {
|
|
193
|
-
badSpy(s.value);
|
|
194
|
-
if (s.value > 0) {
|
|
195
|
-
throw new Error("bad");
|
|
196
|
-
}
|
|
197
|
-
return undefined;
|
|
198
|
-
});
|
|
199
|
-
effect(() => {
|
|
200
|
-
goodSpy(s.value);
|
|
201
|
-
return undefined;
|
|
202
|
-
});
|
|
203
|
-
goodSpy.mockClear();
|
|
204
|
-
badSpy.mockClear();
|
|
205
|
-
errors.length = 0;
|
|
206
|
-
s.value = 5;
|
|
207
|
-
expect(goodSpy).toHaveBeenCalledWith(5);
|
|
208
|
-
expect(badSpy).toHaveBeenCalledWith(5);
|
|
209
|
-
expect(errors.length).toBe(1);
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
// 4. Throw then immediate new dispatch
|
|
216
|
-
// ---------------------------------------------------------------------------
|
|
217
|
-
describe("throw then immediate new dispatch", () => {
|
|
218
|
-
it("write after a throwing flush works correctly", () => {
|
|
219
|
-
const prev = setEffectErrorHandler(() => {
|
|
220
|
-
// swallow
|
|
221
|
-
});
|
|
222
|
-
const s = signal(0);
|
|
223
|
-
const spy = vi.fn();
|
|
224
|
-
effect(() => {
|
|
225
|
-
spy(s.value);
|
|
226
|
-
if (s.value === 1) {
|
|
227
|
-
throw new Error("first-throw");
|
|
228
|
-
}
|
|
229
|
-
return undefined;
|
|
230
|
-
});
|
|
231
|
-
spy.mockClear();
|
|
232
|
-
s.value = 1; // throws inside effect
|
|
233
|
-
spy.mockClear();
|
|
234
|
-
s.value = 2; // must still work
|
|
235
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
236
|
-
setEffectErrorHandler(prev);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("batch, throw, then immediate non-batch write", () => {
|
|
240
|
-
const prev = setEffectErrorHandler(() => {
|
|
241
|
-
// swallow
|
|
242
|
-
});
|
|
243
|
-
const s = signal(0);
|
|
244
|
-
const spy = vi.fn();
|
|
245
|
-
effect(() => {
|
|
246
|
-
spy(s.value);
|
|
247
|
-
if (s.value === 10) {
|
|
248
|
-
throw new Error("batch-throw");
|
|
249
|
-
}
|
|
250
|
-
return undefined;
|
|
251
|
-
});
|
|
252
|
-
spy.mockClear();
|
|
253
|
-
batch(() => {
|
|
254
|
-
s.value = 10;
|
|
255
|
-
});
|
|
256
|
-
spy.mockClear();
|
|
257
|
-
s.value = 20;
|
|
258
|
-
expect(spy).toHaveBeenCalledWith(20);
|
|
259
|
-
setEffectErrorHandler(prev);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("rapid alternating throw/no-throw", () => {
|
|
263
|
-
const prev = setEffectErrorHandler(() => {
|
|
264
|
-
// swallow
|
|
265
|
-
});
|
|
266
|
-
const s = signal(0);
|
|
267
|
-
let lastSeen = -1;
|
|
268
|
-
effect(() => {
|
|
269
|
-
lastSeen = s.value;
|
|
270
|
-
if (s.value % 2 === 1) {
|
|
271
|
-
throw new Error("odd");
|
|
272
|
-
}
|
|
273
|
-
return undefined;
|
|
274
|
-
});
|
|
275
|
-
for (let i = 1; i <= 100; i++) {
|
|
276
|
-
s.value = i;
|
|
277
|
-
}
|
|
278
|
-
expect(lastSeen).toBe(100);
|
|
279
|
-
setEffectErrorHandler(prev);
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// ---------------------------------------------------------------------------
|
|
284
|
-
// 5. Signal equals function throwing (from user code writing to signal)
|
|
285
|
-
// ---------------------------------------------------------------------------
|
|
286
|
-
describe("signal equals function throwing", () => {
|
|
287
|
-
it("equals throw propagates to caller but doesn't corrupt system", () => {
|
|
288
|
-
const s = signal(0, {
|
|
289
|
-
equals: (_a, b) => {
|
|
290
|
-
if (b === 5) {
|
|
291
|
-
throw new Error("equals-boom");
|
|
292
|
-
}
|
|
293
|
-
return _a === b;
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
const spy = vi.fn();
|
|
297
|
-
const prev = setEffectErrorHandler(() => {
|
|
298
|
-
// swallow
|
|
299
|
-
});
|
|
300
|
-
effect(() => {
|
|
301
|
-
spy(s.value);
|
|
302
|
-
return undefined;
|
|
303
|
-
});
|
|
304
|
-
spy.mockClear();
|
|
305
|
-
expect(() => {
|
|
306
|
-
s.value = 5;
|
|
307
|
-
}).toThrow("equals-boom");
|
|
308
|
-
expect(s.peek()).toBe(0);
|
|
309
|
-
expect(spy).not.toHaveBeenCalled();
|
|
310
|
-
s.value = 3;
|
|
311
|
-
expect(spy).toHaveBeenCalledWith(3);
|
|
312
|
-
expect(s.peek()).toBe(3);
|
|
313
|
-
setEffectErrorHandler(prev);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it("equals throw during batch doesn't corrupt batchDepth", () => {
|
|
317
|
-
const s = signal(0, {
|
|
318
|
-
equals: (_a, b) => {
|
|
319
|
-
if (b === 99) {
|
|
320
|
-
throw new Error("eq-batch");
|
|
321
|
-
}
|
|
322
|
-
return false;
|
|
323
|
-
},
|
|
324
|
-
});
|
|
325
|
-
const spy = vi.fn();
|
|
326
|
-
const prev = setEffectErrorHandler(() => {
|
|
327
|
-
// swallow
|
|
328
|
-
});
|
|
329
|
-
effect(() => {
|
|
330
|
-
spy(s.value);
|
|
331
|
-
return undefined;
|
|
332
|
-
});
|
|
333
|
-
spy.mockClear();
|
|
334
|
-
expect(() =>
|
|
335
|
-
batch(() => {
|
|
336
|
-
s.value = 99;
|
|
337
|
-
}),
|
|
338
|
-
).toThrow("eq-batch");
|
|
339
|
-
spy.mockClear();
|
|
340
|
-
s.value = 1;
|
|
341
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
342
|
-
setEffectErrorHandler(prev);
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// ---------------------------------------------------------------------------
|
|
347
|
-
// 6. Dispose inside error handler
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
describe("dispose inside error handler", () => {
|
|
350
|
-
it("handler disposes the throwing effect — no zombie", () => {
|
|
351
|
-
const s = signal(0);
|
|
352
|
-
// eslint-disable-next-line prefer-const
|
|
353
|
-
let dispose: (() => void) | undefined;
|
|
354
|
-
const spy = vi.fn();
|
|
355
|
-
const prev = setEffectErrorHandler(() => {
|
|
356
|
-
dispose?.();
|
|
357
|
-
});
|
|
358
|
-
dispose = effect(() => {
|
|
359
|
-
spy(s.value);
|
|
360
|
-
if (s.value > 0) {
|
|
361
|
-
throw new Error("self-destruct");
|
|
362
|
-
}
|
|
363
|
-
return undefined;
|
|
364
|
-
});
|
|
365
|
-
spy.mockClear();
|
|
366
|
-
s.value = 1;
|
|
367
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
368
|
-
spy.mockClear();
|
|
369
|
-
s.value = 2;
|
|
370
|
-
expect(spy).not.toHaveBeenCalled();
|
|
371
|
-
setEffectErrorHandler(prev);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("handler disposes another effect during flush", () => {
|
|
375
|
-
const s = signal(0);
|
|
376
|
-
// eslint-disable-next-line prefer-const
|
|
377
|
-
let disposeB: (() => void) | undefined;
|
|
378
|
-
const spyA = vi.fn();
|
|
379
|
-
const spyB = vi.fn();
|
|
380
|
-
const prev = setEffectErrorHandler(() => {
|
|
381
|
-
disposeB?.();
|
|
382
|
-
});
|
|
383
|
-
effect(() => {
|
|
384
|
-
spyA(s.value);
|
|
385
|
-
if (s.value > 0) {
|
|
386
|
-
throw new Error("A-throws");
|
|
387
|
-
}
|
|
388
|
-
return undefined;
|
|
389
|
-
});
|
|
390
|
-
disposeB = effect(() => {
|
|
391
|
-
spyB(s.value);
|
|
392
|
-
return undefined;
|
|
393
|
-
});
|
|
394
|
-
spyA.mockClear();
|
|
395
|
-
spyB.mockClear();
|
|
396
|
-
s.value = 1;
|
|
397
|
-
spyA.mockClear();
|
|
398
|
-
spyB.mockClear();
|
|
399
|
-
s.value = 2;
|
|
400
|
-
expect(spyA).toHaveBeenCalledWith(2);
|
|
401
|
-
expect(spyB).not.toHaveBeenCalled();
|
|
402
|
-
setEffectErrorHandler(prev);
|
|
403
|
-
});
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// ---------------------------------------------------------------------------
|
|
407
|
-
// 7. Diamond with dual throwing computeds
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
describe("diamond with dual throwing computeds", () => {
|
|
410
|
-
it("both branches throw — effect sees errors from both", () => {
|
|
411
|
-
const prev = setEffectErrorHandler(() => {
|
|
412
|
-
// swallow
|
|
413
|
-
});
|
|
414
|
-
const root = signal(0);
|
|
415
|
-
const left = computed(() => {
|
|
416
|
-
if (root.value > 0) {
|
|
417
|
-
throw new Error("left-boom");
|
|
418
|
-
}
|
|
419
|
-
return root.value;
|
|
420
|
-
});
|
|
421
|
-
const right = computed(() => {
|
|
422
|
-
if (root.value > 0) {
|
|
423
|
-
throw new Error("right-boom");
|
|
424
|
-
}
|
|
425
|
-
return root.value;
|
|
426
|
-
});
|
|
427
|
-
const log: string[] = [];
|
|
428
|
-
effect(() => {
|
|
429
|
-
const errs: string[] = [];
|
|
430
|
-
try {
|
|
431
|
-
void left.value;
|
|
432
|
-
} catch (e) {
|
|
433
|
-
errs.push((e as Error).message);
|
|
434
|
-
}
|
|
435
|
-
try {
|
|
436
|
-
void right.value;
|
|
437
|
-
} catch (e) {
|
|
438
|
-
errs.push((e as Error).message);
|
|
439
|
-
}
|
|
440
|
-
log.push(errs.join(",") || "ok");
|
|
441
|
-
return undefined;
|
|
442
|
-
});
|
|
443
|
-
log.length = 0;
|
|
444
|
-
root.value = 1;
|
|
445
|
-
expect(log.some((entry) => entry.includes("left-boom") && entry.includes("right-boom"))).toBe(
|
|
446
|
-
true,
|
|
447
|
-
);
|
|
448
|
-
// Recovery
|
|
449
|
-
log.length = 0;
|
|
450
|
-
root.value = -1;
|
|
451
|
-
expect(log.some((entry) => entry === "ok")).toBe(true);
|
|
452
|
-
setEffectErrorHandler(prev);
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
// ---------------------------------------------------------------------------
|
|
457
|
-
// 8. Nested batch where inner batch callback throws
|
|
458
|
-
// ---------------------------------------------------------------------------
|
|
459
|
-
describe("nested batch inner throw", () => {
|
|
460
|
-
it("inner batch throws — outer batch still flushes", () => {
|
|
461
|
-
const prev = setEffectErrorHandler(() => {
|
|
462
|
-
// swallow
|
|
463
|
-
});
|
|
464
|
-
const s = signal(0);
|
|
465
|
-
const spy = vi.fn();
|
|
466
|
-
effect(() => {
|
|
467
|
-
spy(s.value);
|
|
468
|
-
return undefined;
|
|
469
|
-
});
|
|
470
|
-
spy.mockClear();
|
|
471
|
-
expect(() => {
|
|
472
|
-
batch(() => {
|
|
473
|
-
s.value = 10;
|
|
474
|
-
batch(() => {
|
|
475
|
-
throw new Error("inner-batch-throw");
|
|
476
|
-
});
|
|
477
|
-
});
|
|
478
|
-
}).toThrow("inner-batch-throw");
|
|
479
|
-
spy.mockClear();
|
|
480
|
-
s.value = 20;
|
|
481
|
-
expect(spy).toHaveBeenCalledWith(20);
|
|
482
|
-
setEffectErrorHandler(prev);
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it("outer batch throws after inner batch — batchDepth correct", () => {
|
|
486
|
-
const prev = setEffectErrorHandler(() => {
|
|
487
|
-
// swallow
|
|
488
|
-
});
|
|
489
|
-
const s = signal(0);
|
|
490
|
-
const spy = vi.fn();
|
|
491
|
-
effect(() => {
|
|
492
|
-
spy(s.value);
|
|
493
|
-
return undefined;
|
|
494
|
-
});
|
|
495
|
-
spy.mockClear();
|
|
496
|
-
expect(() => {
|
|
497
|
-
batch(() => {
|
|
498
|
-
batch(() => {
|
|
499
|
-
s.value = 5;
|
|
500
|
-
});
|
|
501
|
-
throw new Error("outer-throw");
|
|
502
|
-
});
|
|
503
|
-
}).toThrow("outer-throw");
|
|
504
|
-
spy.mockClear();
|
|
505
|
-
s.value = 6;
|
|
506
|
-
expect(spy).toHaveBeenCalledWith(6);
|
|
507
|
-
setEffectErrorHandler(prev);
|
|
508
|
-
});
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
// ---------------------------------------------------------------------------
|
|
512
|
-
// 9. Effect body writes to signal that triggers other effects — one throws
|
|
513
|
-
// ---------------------------------------------------------------------------
|
|
514
|
-
describe("effect body writes trigger cascade with throw", () => {
|
|
515
|
-
it("effect A writes to s2, effect B on s2 throws — A still re-subscribes", () => {
|
|
516
|
-
const prev = setEffectErrorHandler(() => {
|
|
517
|
-
// swallow
|
|
518
|
-
});
|
|
519
|
-
const s1 = signal(0);
|
|
520
|
-
const s2 = signal(0);
|
|
521
|
-
const spyA = vi.fn();
|
|
522
|
-
effect(() => {
|
|
523
|
-
spyA(s1.value);
|
|
524
|
-
s2.value = s1.value * 10;
|
|
525
|
-
return undefined;
|
|
526
|
-
});
|
|
527
|
-
effect(() => {
|
|
528
|
-
if (s2.value > 0) {
|
|
529
|
-
throw new Error("B-throws");
|
|
530
|
-
}
|
|
531
|
-
return undefined;
|
|
532
|
-
});
|
|
533
|
-
spyA.mockClear();
|
|
534
|
-
s1.value = 1;
|
|
535
|
-
spyA.mockClear();
|
|
536
|
-
s1.value = 2;
|
|
537
|
-
expect(spyA).toHaveBeenCalledWith(2);
|
|
538
|
-
setEffectErrorHandler(prev);
|
|
539
|
-
});
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
// ---------------------------------------------------------------------------
|
|
543
|
-
// 10. Verify flushing flag is never stuck (direct invariant check)
|
|
544
|
-
// ---------------------------------------------------------------------------
|
|
545
|
-
describe("flushing invariant is never stuck", () => {
|
|
546
|
-
it("after many throws, flushing is false (flushSync works)", () => {
|
|
547
|
-
const prev = setEffectErrorHandler(() => {
|
|
548
|
-
// swallow
|
|
549
|
-
});
|
|
550
|
-
const s = signal(0);
|
|
551
|
-
for (let i = 0; i < 50; i++) {
|
|
552
|
-
effect(() => {
|
|
553
|
-
if (s.value > 0) {
|
|
554
|
-
throw new Error(`stuck-${i}`);
|
|
555
|
-
}
|
|
556
|
-
return undefined;
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
s.value = 1;
|
|
560
|
-
const spy = vi.fn();
|
|
561
|
-
effect(() => {
|
|
562
|
-
spy(s.value);
|
|
563
|
-
return undefined;
|
|
564
|
-
});
|
|
565
|
-
spy.mockClear();
|
|
566
|
-
s.value = 2;
|
|
567
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
568
|
-
setEffectErrorHandler(prev);
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
it("batch with only throwing effects — batchDepth returns to 0", () => {
|
|
572
|
-
const prev = setEffectErrorHandler(() => {
|
|
573
|
-
// swallow
|
|
574
|
-
});
|
|
575
|
-
const s = signal(0);
|
|
576
|
-
effect(() => {
|
|
577
|
-
if (s.value > 0) {
|
|
578
|
-
throw new Error("batch-stuck");
|
|
579
|
-
}
|
|
580
|
-
return undefined;
|
|
581
|
-
});
|
|
582
|
-
batch(() => {
|
|
583
|
-
s.value = 1;
|
|
584
|
-
});
|
|
585
|
-
const spy = vi.fn();
|
|
586
|
-
effect(() => {
|
|
587
|
-
spy(s.value);
|
|
588
|
-
return undefined;
|
|
589
|
-
});
|
|
590
|
-
spy.mockClear();
|
|
591
|
-
s.value = 99;
|
|
592
|
-
expect(spy).toHaveBeenCalledWith(99);
|
|
593
|
-
setEffectErrorHandler(prev);
|
|
594
|
-
});
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
// ---------------------------------------------------------------------------
|
|
598
|
-
// 11. Computed cycle detection doesn't corrupt state
|
|
599
|
-
// ---------------------------------------------------------------------------
|
|
600
|
-
describe("computed cycle detection", () => {
|
|
601
|
-
it("cycle throws on access but doesn't leave system broken", () => {
|
|
602
|
-
// eslint-disable-next-line prefer-const
|
|
603
|
-
let c2: { readonly value: number };
|
|
604
|
-
const c1 = computed(() => (c2 as { readonly value: number }).value + 1);
|
|
605
|
-
c2 = computed(() => c1.value + 1);
|
|
606
|
-
expect(() => c1.value).toThrow("Cycle detected");
|
|
607
|
-
const s = signal(42);
|
|
608
|
-
const spy = vi.fn();
|
|
609
|
-
const prev = setEffectErrorHandler(() => {
|
|
610
|
-
// swallow
|
|
611
|
-
});
|
|
612
|
-
const d = effect(() => {
|
|
613
|
-
spy(s.value);
|
|
614
|
-
return undefined;
|
|
615
|
-
});
|
|
616
|
-
spy.mockClear();
|
|
617
|
-
s.value = 100;
|
|
618
|
-
expect(spy).toHaveBeenCalledWith(100);
|
|
619
|
-
d();
|
|
620
|
-
setEffectErrorHandler(prev);
|
|
621
|
-
});
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
// ---------------------------------------------------------------------------
|
|
625
|
-
// 12. Cleanup writes to signal (re-entrant during flush)
|
|
626
|
-
// ---------------------------------------------------------------------------
|
|
627
|
-
describe("cleanup writes to signal during flush", () => {
|
|
628
|
-
it("cleanup writes to a signal — downstream effect picks it up", () => {
|
|
629
|
-
const prev = setEffectErrorHandler(() => {
|
|
630
|
-
// swallow
|
|
631
|
-
});
|
|
632
|
-
const s = signal(0);
|
|
633
|
-
const s2 = signal("x");
|
|
634
|
-
const log: string[] = [];
|
|
635
|
-
effect(() => {
|
|
636
|
-
void s.value;
|
|
637
|
-
return () => {
|
|
638
|
-
s2.value = `cleaned-${s.peek()}`;
|
|
639
|
-
};
|
|
640
|
-
});
|
|
641
|
-
effect(() => {
|
|
642
|
-
log.push(s2.value);
|
|
643
|
-
return undefined;
|
|
644
|
-
});
|
|
645
|
-
log.length = 0;
|
|
646
|
-
s.value = 1;
|
|
647
|
-
expect(log).toContain("cleaned-1");
|
|
648
|
-
setEffectErrorHandler(prev);
|
|
649
|
-
});
|
|
650
|
-
});
|