@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.
@@ -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
- });