@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/redteam4.test.ts
DELETED
|
@@ -1,410 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
// RED-TEAM round-4 convergence check
|
|
3
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
4
|
-
import { signal, effect, batch, computed, setEffectErrorHandler, createStore } from "./index.js";
|
|
5
|
-
import type { EffectErrorHandler } from "./index.js";
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// 1. Cleanup throws AND body throws in same re-execution
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
describe("cleanup throw + body throw in same run", () => {
|
|
12
|
-
let prevHandler: EffectErrorHandler;
|
|
13
|
-
const errors: unknown[] = [];
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
errors.length = 0;
|
|
16
|
-
prevHandler = setEffectErrorHandler((e) => {
|
|
17
|
-
errors.push(e);
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
setEffectErrorHandler(prevHandler);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("both cleanup and body throw: both errors routed to handler, effect re-subscribes", () => {
|
|
25
|
-
const s = signal(0);
|
|
26
|
-
const values: number[] = [];
|
|
27
|
-
effect(() => {
|
|
28
|
-
const v = s.value;
|
|
29
|
-
values.push(v);
|
|
30
|
-
if (v === 1) {
|
|
31
|
-
throw new Error("body-throw-1");
|
|
32
|
-
}
|
|
33
|
-
return () => {
|
|
34
|
-
throw new Error("cleanup-throw");
|
|
35
|
-
};
|
|
36
|
-
});
|
|
37
|
-
// Trigger: cleanup throws then body throws
|
|
38
|
-
s.value = 1;
|
|
39
|
-
expect(errors.some((e) => (e as Error).message === "cleanup-throw")).toBe(true);
|
|
40
|
-
expect(errors.some((e) => (e as Error).message === "body-throw-1")).toBe(true);
|
|
41
|
-
// Effect should still re-execute on next change
|
|
42
|
-
errors.length = 0;
|
|
43
|
-
s.value = 2;
|
|
44
|
-
expect(values).toContain(2);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("cleanup throw does not prevent body from running (ordering)", () => {
|
|
48
|
-
const s = signal("a");
|
|
49
|
-
const log: string[] = [];
|
|
50
|
-
effect(() => {
|
|
51
|
-
log.push(`body:${s.value}`);
|
|
52
|
-
return () => {
|
|
53
|
-
log.push(`cleanup:${s.value}`);
|
|
54
|
-
throw new Error("cleanup!");
|
|
55
|
-
};
|
|
56
|
-
});
|
|
57
|
-
s.value = "b";
|
|
58
|
-
// cleanup runs first (with old captured value from closure — but s.value is already "b")
|
|
59
|
-
// body runs second
|
|
60
|
-
expect(log).toContain("body:b");
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
// 2. Cleanup throwing across many effects in a single flush
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
|
-
describe("many effects' cleanups throw in single flush", () => {
|
|
69
|
-
let prevHandler: EffectErrorHandler;
|
|
70
|
-
const errors: unknown[] = [];
|
|
71
|
-
beforeEach(() => {
|
|
72
|
-
errors.length = 0;
|
|
73
|
-
prevHandler = setEffectErrorHandler((e) => {
|
|
74
|
-
errors.push(e);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
afterEach(() => {
|
|
78
|
-
setEffectErrorHandler(prevHandler);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("10 effects all with throwing cleanups: all re-execute correctly", () => {
|
|
82
|
-
const s = signal(0);
|
|
83
|
-
const spies = Array.from({ length: 10 }, () => vi.fn());
|
|
84
|
-
for (let i = 0; i < 10; i++) {
|
|
85
|
-
const spy = spies[i]!;
|
|
86
|
-
effect(() => {
|
|
87
|
-
spy(s.value);
|
|
88
|
-
return () => {
|
|
89
|
-
throw new Error(`cleanup-${i}`);
|
|
90
|
-
};
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
for (const spy of spies) {
|
|
94
|
-
spy.mockClear();
|
|
95
|
-
}
|
|
96
|
-
s.value = 1;
|
|
97
|
-
// All effects should re-execute despite all cleanups throwing
|
|
98
|
-
for (const spy of spies) {
|
|
99
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
100
|
-
}
|
|
101
|
-
expect(errors.length).toBe(10); // 10 cleanup errors
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("batch where multiple effects' cleanups throw: all flush correctly", () => {
|
|
105
|
-
const s = signal(0);
|
|
106
|
-
const results: number[] = [];
|
|
107
|
-
for (let i = 0; i < 5; i++) {
|
|
108
|
-
effect(() => {
|
|
109
|
-
results.push(s.value);
|
|
110
|
-
return () => {
|
|
111
|
-
throw new Error(`batch-cleanup-${i}`);
|
|
112
|
-
};
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
results.length = 0;
|
|
116
|
-
errors.length = 0;
|
|
117
|
-
batch(() => {
|
|
118
|
-
s.value = 99;
|
|
119
|
-
});
|
|
120
|
-
expect(results.filter((v) => v === 99).length).toBe(5);
|
|
121
|
-
expect(errors.length).toBe(5);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
// 3. Dispose during cleanup-throw (dispose() must not throw to caller)
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
|
|
129
|
-
describe("dispose during cleanup throw", () => {
|
|
130
|
-
let prevHandler: EffectErrorHandler;
|
|
131
|
-
const errors: unknown[] = [];
|
|
132
|
-
beforeEach(() => {
|
|
133
|
-
errors.length = 0;
|
|
134
|
-
prevHandler = setEffectErrorHandler((e) => {
|
|
135
|
-
errors.push(e);
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
afterEach(() => {
|
|
139
|
-
setEffectErrorHandler(prevHandler);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("dispose() when cleanup throws: error goes to handler, does not propagate", () => {
|
|
143
|
-
const s = signal(0);
|
|
144
|
-
const dispose = effect(() => {
|
|
145
|
-
void s.value;
|
|
146
|
-
return () => {
|
|
147
|
-
throw new Error("dispose-cleanup-throw");
|
|
148
|
-
};
|
|
149
|
-
});
|
|
150
|
-
// dispose must NOT throw
|
|
151
|
-
expect(() => dispose()).not.toThrow();
|
|
152
|
-
expect(errors.some((e) => (e as Error).message === "dispose-cleanup-throw")).toBe(true);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("after dispose with cleanup throw, signal no longer triggers effect", () => {
|
|
156
|
-
const s = signal(0);
|
|
157
|
-
const spy = vi.fn();
|
|
158
|
-
const dispose = effect(() => {
|
|
159
|
-
spy(s.value);
|
|
160
|
-
return () => {
|
|
161
|
-
throw new Error("dispose-cleanup");
|
|
162
|
-
};
|
|
163
|
-
});
|
|
164
|
-
spy.mockClear();
|
|
165
|
-
dispose();
|
|
166
|
-
s.value = 1;
|
|
167
|
-
expect(spy).not.toHaveBeenCalled();
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
// 4. Computed cleanup paths (computed that throws during recompute in effect)
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
describe("computed cleanup paths", () => {
|
|
176
|
-
let prevHandler: EffectErrorHandler;
|
|
177
|
-
const errors: unknown[] = [];
|
|
178
|
-
beforeEach(() => {
|
|
179
|
-
errors.length = 0;
|
|
180
|
-
prevHandler = setEffectErrorHandler((e) => {
|
|
181
|
-
errors.push(e);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
afterEach(() => {
|
|
185
|
-
setEffectErrorHandler(prevHandler);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("effect reading computed that throws: error in handler, effect recovers", () => {
|
|
189
|
-
const s = signal(0);
|
|
190
|
-
const c = computed(() => {
|
|
191
|
-
if (s.value === 1) {
|
|
192
|
-
throw new Error("computed-throw");
|
|
193
|
-
}
|
|
194
|
-
return s.value * 10;
|
|
195
|
-
});
|
|
196
|
-
const values: (number | string)[] = [];
|
|
197
|
-
effect(() => {
|
|
198
|
-
try {
|
|
199
|
-
values.push(c.value);
|
|
200
|
-
} catch (e) {
|
|
201
|
-
values.push((e as Error).message);
|
|
202
|
-
}
|
|
203
|
-
return undefined;
|
|
204
|
-
});
|
|
205
|
-
s.value = 1;
|
|
206
|
-
s.value = 2;
|
|
207
|
-
expect(values).toContain(0); // initial
|
|
208
|
-
expect(values).toContain("computed-throw"); // error state
|
|
209
|
-
expect(values).toContain(20); // recovery
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("computed throwing does not leave dirty flag stuck", () => {
|
|
213
|
-
const s = signal(0);
|
|
214
|
-
const c = computed(() => {
|
|
215
|
-
if (s.value === 1) {
|
|
216
|
-
throw new Error("stuck?");
|
|
217
|
-
}
|
|
218
|
-
return s.value;
|
|
219
|
-
});
|
|
220
|
-
expect(c.value).toBe(0);
|
|
221
|
-
s.value = 1;
|
|
222
|
-
expect(() => c.value).toThrow("stuck?");
|
|
223
|
-
s.value = 2;
|
|
224
|
-
expect(c.value).toBe(2); // not stuck
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
// 5. Store effect cleanup throw
|
|
230
|
-
// ---------------------------------------------------------------------------
|
|
231
|
-
|
|
232
|
-
describe("store effect cleanup throw", () => {
|
|
233
|
-
it("store effect cleanup throw does not crash store, effect re-runs", () => {
|
|
234
|
-
const store = createStore<{ x: number }>();
|
|
235
|
-
store.set("x", 0);
|
|
236
|
-
const values: number[] = [];
|
|
237
|
-
const origErr = console.error;
|
|
238
|
-
const errSpy = vi.fn();
|
|
239
|
-
console.error = errSpy;
|
|
240
|
-
store.effect(() => {
|
|
241
|
-
values.push(store.get("x"));
|
|
242
|
-
return () => {
|
|
243
|
-
throw new Error("store-cleanup-boom");
|
|
244
|
-
};
|
|
245
|
-
});
|
|
246
|
-
store.set("x", 1);
|
|
247
|
-
store.set("x", 2);
|
|
248
|
-
console.error = origErr;
|
|
249
|
-
expect(values).toContain(0);
|
|
250
|
-
expect(values).toContain(1);
|
|
251
|
-
expect(values).toContain(2);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("store dispose with throwing cleanup does not throw to caller", () => {
|
|
255
|
-
const store = createStore<{ x: number }>();
|
|
256
|
-
store.set("x", 0);
|
|
257
|
-
const origErr = console.error;
|
|
258
|
-
console.error = vi.fn();
|
|
259
|
-
const dispose = store.effect(() => {
|
|
260
|
-
void store.get("x");
|
|
261
|
-
return () => {
|
|
262
|
-
throw new Error("store-dispose-cleanup");
|
|
263
|
-
};
|
|
264
|
-
});
|
|
265
|
-
expect(() => dispose()).not.toThrow();
|
|
266
|
-
console.error = origErr;
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
|
-
// 6. Exception inside effectErrorHandler itself
|
|
272
|
-
// ---------------------------------------------------------------------------
|
|
273
|
-
|
|
274
|
-
describe("exception inside effectErrorHandler", () => {
|
|
275
|
-
let prevHandler: EffectErrorHandler;
|
|
276
|
-
beforeEach(() => {
|
|
277
|
-
prevHandler = setEffectErrorHandler(() => {
|
|
278
|
-
/* swallow */
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
afterEach(() => {
|
|
282
|
-
setEffectErrorHandler(prevHandler);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("if effectErrorHandler throws, system remains usable", () => {
|
|
286
|
-
setEffectErrorHandler(() => {
|
|
287
|
-
throw new Error("handler-throws");
|
|
288
|
-
});
|
|
289
|
-
const s = signal(0);
|
|
290
|
-
// Effect that throws
|
|
291
|
-
effect(() => {
|
|
292
|
-
if (s.value === 1) {
|
|
293
|
-
throw new Error("trigger");
|
|
294
|
-
}
|
|
295
|
-
return undefined;
|
|
296
|
-
});
|
|
297
|
-
// This will cause effectErrorHandler to be called, which itself throws
|
|
298
|
-
// The system should still work after (drainPending has try/finally)
|
|
299
|
-
try {
|
|
300
|
-
s.value = 1;
|
|
301
|
-
} catch {
|
|
302
|
-
/* expected */
|
|
303
|
-
}
|
|
304
|
-
// Reset handler and verify system still works
|
|
305
|
-
const errors: unknown[] = [];
|
|
306
|
-
setEffectErrorHandler((e) => errors.push(e));
|
|
307
|
-
const spy = vi.fn();
|
|
308
|
-
effect(() => {
|
|
309
|
-
spy(s.value);
|
|
310
|
-
return undefined;
|
|
311
|
-
});
|
|
312
|
-
spy.mockClear();
|
|
313
|
-
s.value = 2;
|
|
314
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it("if effectErrorHandler throws during cleanup, next effects still run", () => {
|
|
318
|
-
setEffectErrorHandler(() => {
|
|
319
|
-
throw new Error("handler-boom");
|
|
320
|
-
});
|
|
321
|
-
const s = signal(0);
|
|
322
|
-
const spy = vi.fn();
|
|
323
|
-
effect(() => {
|
|
324
|
-
void s.value;
|
|
325
|
-
return () => {
|
|
326
|
-
throw new Error("cleanup!");
|
|
327
|
-
};
|
|
328
|
-
});
|
|
329
|
-
effect(() => {
|
|
330
|
-
spy(s.value);
|
|
331
|
-
return undefined;
|
|
332
|
-
});
|
|
333
|
-
spy.mockClear();
|
|
334
|
-
// Cleanup will throw -> handler will throw -> but drainPending catches per-effect
|
|
335
|
-
try {
|
|
336
|
-
s.value = 1;
|
|
337
|
-
} catch {
|
|
338
|
-
/* may propagate */
|
|
339
|
-
}
|
|
340
|
-
// Check if the second effect still ran
|
|
341
|
-
// NOTE: If effectErrorHandler throws, it propagates from the try/catch in execute()
|
|
342
|
-
// which is itself inside a try/catch in drainPending. Let's verify.
|
|
343
|
-
setEffectErrorHandler(() => {
|
|
344
|
-
/* swallow */
|
|
345
|
-
});
|
|
346
|
-
spy.mockClear();
|
|
347
|
-
s.value = 2;
|
|
348
|
-
expect(spy).toHaveBeenCalledWith(2);
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
// ---------------------------------------------------------------------------
|
|
353
|
-
// 7. Fake timers: verify no async leaks
|
|
354
|
-
// ---------------------------------------------------------------------------
|
|
355
|
-
|
|
356
|
-
describe("fake timers: no async leaks in error paths", () => {
|
|
357
|
-
beforeEach(() => {
|
|
358
|
-
vi.useFakeTimers();
|
|
359
|
-
});
|
|
360
|
-
afterEach(() => {
|
|
361
|
-
vi.useRealTimers();
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
let prevHandler: EffectErrorHandler;
|
|
365
|
-
beforeEach(() => {
|
|
366
|
-
prevHandler = setEffectErrorHandler(() => {
|
|
367
|
-
/* swallow */
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
afterEach(() => {
|
|
371
|
-
setEffectErrorHandler(prevHandler);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("cleanup throw + re-execution is fully synchronous", () => {
|
|
375
|
-
const s = signal(0);
|
|
376
|
-
const spy = vi.fn();
|
|
377
|
-
effect(() => {
|
|
378
|
-
spy(s.value);
|
|
379
|
-
return () => {
|
|
380
|
-
throw new Error("sync-cleanup");
|
|
381
|
-
};
|
|
382
|
-
});
|
|
383
|
-
spy.mockClear();
|
|
384
|
-
s.value = 1;
|
|
385
|
-
// Must be called already, no microtask
|
|
386
|
-
expect(spy).toHaveBeenCalledWith(1);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
it("batch + multiple cleanup throws + body throws: all synchronous", () => {
|
|
390
|
-
const s = signal(0);
|
|
391
|
-
const results: number[] = [];
|
|
392
|
-
for (let i = 0; i < 3; i++) {
|
|
393
|
-
effect(() => {
|
|
394
|
-
results.push(s.value);
|
|
395
|
-
if (s.value === 1) {
|
|
396
|
-
throw new Error(`body-${i}`);
|
|
397
|
-
}
|
|
398
|
-
return () => {
|
|
399
|
-
throw new Error(`cleanup-${i}`);
|
|
400
|
-
};
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
results.length = 0;
|
|
404
|
-
batch(() => {
|
|
405
|
-
s.value = 1;
|
|
406
|
-
});
|
|
407
|
-
// All should have attempted to run synchronously
|
|
408
|
-
expect(results.length).toBe(3);
|
|
409
|
-
});
|
|
410
|
-
});
|