@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.
@@ -1,552 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // RED-TEAM adversarial tests probing edge cases
3
- import { describe, it, expect, vi } from "vitest";
4
- import {
5
- signal,
6
- effect,
7
- batch,
8
- computed,
9
- untracked,
10
- subscribe,
11
- setEffectErrorHandler,
12
- reconcile,
13
- patch,
14
- reconcileChildren,
15
- createStore,
16
- } from "./index.js";
17
-
18
- // ---------------------------------------------------------------------------
19
- // Effect disposal edge cases
20
- // ---------------------------------------------------------------------------
21
-
22
- describe("effect disposal edge cases", () => {
23
- it("dispose during own execution does not crash", () => {
24
- const s = signal(0);
25
- const spy = vi.fn();
26
- const disposeFn: { current: () => void } = {
27
- current: () => {
28
- /* placeholder */
29
- },
30
- };
31
- disposeFn.current = effect(() => {
32
- spy(s.value);
33
- if (s.value === 1) {
34
- disposeFn.current(); // dispose self mid-run
35
- }
36
- return undefined;
37
- });
38
- expect(spy).toHaveBeenCalledWith(0);
39
- s.value = 1; // triggers re-run which disposes
40
- expect(spy).toHaveBeenCalledWith(1);
41
- spy.mockClear();
42
- s.value = 2; // should NOT trigger since disposed
43
- expect(spy).not.toHaveBeenCalled();
44
- });
45
-
46
- it("double dispose does not crash or double-call cleanup", () => {
47
- const s = signal(0);
48
- let cleanupCount = 0;
49
- const dispose = effect(() => {
50
- void s.value;
51
- return () => {
52
- cleanupCount++;
53
- };
54
- });
55
- dispose();
56
- expect(cleanupCount).toBe(1);
57
- dispose(); // second dispose
58
- expect(cleanupCount).toBe(1); // should NOT call cleanup again
59
- });
60
-
61
- it("dispose mid-batch does not crash", () => {
62
- const s = signal(0);
63
- const spy = vi.fn();
64
- const dispose = effect(() => {
65
- spy(s.value);
66
- return undefined;
67
- });
68
- spy.mockClear();
69
- batch(() => {
70
- s.value = 1;
71
- dispose(); // dispose while batch is pending
72
- s.value = 2;
73
- });
74
- // Effect was disposed mid-batch, should not run
75
- expect(spy).not.toHaveBeenCalled();
76
- });
77
- });
78
-
79
- // ---------------------------------------------------------------------------
80
- // Re-entrancy: signal write inside effect
81
- // ---------------------------------------------------------------------------
82
-
83
- describe("re-entrancy", () => {
84
- it("signal write inside effect triggers re-run after current flush", () => {
85
- const s = signal(0);
86
- const log: number[] = [];
87
- effect(() => {
88
- log.push(s.value);
89
- if (s.value < 3) {
90
- s.value = s.value + 1; // re-entrant write
91
- }
92
- return undefined;
93
- });
94
- // Should converge: 0, 1, 2, 3
95
- expect(log).toEqual([0, 1, 2, 3]);
96
- });
97
-
98
- it("signal write inside effect does not cause infinite loop with guard", () => {
99
- const s = signal(0);
100
- let runs = 0;
101
- effect(() => {
102
- runs++;
103
- if (s.value < 100) {
104
- s.value = s.value + 1;
105
- }
106
- return undefined;
107
- });
108
- expect(runs).toBe(101); // 0..100
109
- expect(s.peek()).toBe(100);
110
- });
111
- });
112
-
113
- // ---------------------------------------------------------------------------
114
- // Batch nesting + exceptions mid-batch
115
- // ---------------------------------------------------------------------------
116
-
117
- describe("batch nesting + exceptions", () => {
118
- it("exception mid-batch still flushes pending effects", () => {
119
- const s = signal(0);
120
- const spy = vi.fn();
121
- effect(() => {
122
- spy(s.value);
123
- return undefined;
124
- });
125
- spy.mockClear();
126
- expect(() => {
127
- batch(() => {
128
- s.value = 42;
129
- throw new Error("mid-batch");
130
- });
131
- }).toThrow("mid-batch");
132
- // batchDepth should be back to 0 and pending effects flushed
133
- expect(spy).toHaveBeenCalledWith(42);
134
- });
135
-
136
- it("nested batch exception: outer batch still flushes", () => {
137
- const s = signal(0);
138
- const spy = vi.fn();
139
- effect(() => {
140
- spy(s.value);
141
- return undefined;
142
- });
143
- spy.mockClear();
144
- batch(() => {
145
- s.value = 1;
146
- try {
147
- batch(() => {
148
- s.value = 2;
149
- throw new Error("inner");
150
- });
151
- } catch {
152
- // swallow inner error
153
- }
154
- s.value = 3;
155
- });
156
- // Should have flushed with final value
157
- expect(spy).toHaveBeenCalledWith(3);
158
- });
159
-
160
- it("batchDepth is not left dirty after exception", () => {
161
- const s = signal(0);
162
- const spy = vi.fn();
163
- effect(() => {
164
- spy(s.value);
165
- return undefined;
166
- });
167
- spy.mockClear();
168
- try {
169
- batch(() => {
170
- throw new Error("oops");
171
- });
172
- } catch {
173
- // swallow
174
- }
175
- // Subsequent writes should flush immediately (not batched)
176
- s.value = 99;
177
- expect(spy).toHaveBeenCalledWith(99);
178
- });
179
- });
180
-
181
- // ---------------------------------------------------------------------------
182
- // Computed error caching invalidation
183
- // ---------------------------------------------------------------------------
184
-
185
- describe("computed error caching invalidation", () => {
186
- it("error is invalidated when dependency changes", () => {
187
- const s = signal(0);
188
- let computeCount = 0;
189
- const c = computed(() => {
190
- computeCount++;
191
- if (s.value === 0) {
192
- throw new Error("zero");
193
- }
194
- return s.value;
195
- });
196
- expect(() => c.value).toThrow("zero");
197
- expect(computeCount).toBe(1);
198
- // Reading again without dep change should use cached error
199
- expect(() => c.value).toThrow("zero");
200
- expect(computeCount).toBe(1);
201
- // Change dep - should recompute
202
- s.value = 5;
203
- expect(c.value).toBe(5);
204
- expect(computeCount).toBe(2);
205
- });
206
-
207
- it("computed error in effect: recovers after dep change", () => {
208
- const s = signal(0);
209
- const errors: unknown[] = [];
210
- const values: unknown[] = [];
211
- const prev = setEffectErrorHandler((e) => {
212
- errors.push(e);
213
- });
214
- effect(() => {
215
- values.push(
216
- computed(() => {
217
- if (s.value === 0) {
218
- throw new Error("err");
219
- }
220
- return s.value;
221
- }).value,
222
- );
223
- return undefined;
224
- });
225
- expect(errors.length).toBe(1);
226
- s.value = 1;
227
- // The effect re-runs and creates a new computed each time, so it should work
228
- expect(values).toContain(1);
229
- setEffectErrorHandler(prev);
230
- });
231
- });
232
-
233
- // ---------------------------------------------------------------------------
234
- // Custom equals throwing
235
- // ---------------------------------------------------------------------------
236
-
237
- describe("custom equals throwing", () => {
238
- it("signal: exception in equals propagates to caller", () => {
239
- const s = signal(1, {
240
- equals: () => {
241
- throw new Error("equals boom");
242
- },
243
- });
244
- expect(() => {
245
- s.value = 2;
246
- }).toThrow("equals boom");
247
- });
248
-
249
- it("computed: exception in equals during execute is caught and downstream still notified", () => {
250
- const s = signal(1);
251
- const c = computed(() => s.value, {
252
- equals: () => {
253
- throw new Error("eq error");
254
- },
255
- });
256
- // First read works (no equals comparison on first compute via ensureFresh)
257
- expect(c.value).toBe(1);
258
- // Subscribe an effect that reads the computed
259
- const values: number[] = [];
260
- effect(() => {
261
- values.push(c.value);
262
- return undefined;
263
- });
264
- expect(values).toEqual([1]);
265
- // Now change dep - execute() will compare using equals, which throws
266
- // The equals error is swallowed (treated as "changed"), downstream is notified
267
- s.value = 2;
268
- expect(values).toEqual([1, 2]);
269
- // System should still work after the error
270
- const s2 = signal(0);
271
- const spy = vi.fn();
272
- effect(() => {
273
- spy(s2.value);
274
- return undefined;
275
- });
276
- spy.mockClear();
277
- s2.value = 42;
278
- expect(spy).toHaveBeenCalledWith(42);
279
- });
280
- });
281
-
282
- // ---------------------------------------------------------------------------
283
- // Memory leaks: disposed effects fully unsubscribe
284
- // ---------------------------------------------------------------------------
285
-
286
- describe("memory leaks: disposed effects unsubscribe", () => {
287
- it("disposed effect is removed from signal subscriber set", () => {
288
- const s = signal(0);
289
- const spy = vi.fn();
290
- const dispose = effect(() => {
291
- spy(s.value);
292
- return undefined;
293
- });
294
- dispose();
295
- spy.mockClear();
296
- s.value = 1;
297
- expect(spy).not.toHaveBeenCalled();
298
- // Verify no lingering references by triggering GC-like behavior
299
- // The signal's subs set should be empty
300
- });
301
-
302
- it("disposed subscribe is removed from signal subscriber set", () => {
303
- const s = signal(0);
304
- const spy = vi.fn();
305
- const dispose = subscribe(s, spy);
306
- dispose();
307
- spy.mockClear();
308
- s.value = 1;
309
- expect(spy).not.toHaveBeenCalled();
310
- });
311
-
312
- it("computed that loses all subscribers still recomputes on dep change (no stale)", () => {
313
- const s = signal(1);
314
- const c = computed(() => s.value * 2);
315
- const spy = vi.fn();
316
- const dispose = effect(() => {
317
- spy(c.value);
318
- return undefined;
319
- });
320
- expect(spy).toHaveBeenCalledWith(2);
321
- dispose();
322
- s.value = 5;
323
- // Reading computed lazily should still give correct value
324
- expect(c.value).toBe(10);
325
- });
326
- });
327
-
328
- // ---------------------------------------------------------------------------
329
- // Deep diamond graph
330
- // ---------------------------------------------------------------------------
331
-
332
- describe("deep diamond graph", () => {
333
- it("deep diamond: effect runs once", () => {
334
- const root = signal(1);
335
- const a = computed(() => root.value + 1);
336
- const b = computed(() => root.value + 2);
337
- const c = computed(() => a.value + b.value);
338
- const d = computed(() => c.value * 2);
339
- const spy = vi.fn();
340
- effect(() => {
341
- spy(d.value);
342
- return undefined;
343
- });
344
- expect(spy).toHaveBeenCalledWith((1 + 1 + 1 + 2) * 2); // (2+3)*2 = 10
345
- spy.mockClear();
346
- root.value = 10;
347
- // d = (a+b)*2 = ((10+1)+(10+2))*2 = (11+12)*2 = 46
348
- expect(spy).toHaveBeenCalledTimes(1);
349
- expect(spy).toHaveBeenCalledWith(46);
350
- });
351
-
352
- it("wide diamond: many computeds from same source", () => {
353
- const src = signal(0);
354
- const branches = Array.from({ length: 10 }, (_, i) => computed(() => src.value + i));
355
- const sum = computed(() => branches.reduce((acc, b) => acc + b.value, 0));
356
- const spy = vi.fn();
357
- effect(() => {
358
- spy(sum.value);
359
- return undefined;
360
- });
361
- spy.mockClear();
362
- src.value = 1;
363
- expect(spy).toHaveBeenCalledTimes(1);
364
- // sum = (1+0)+(1+1)+...+(1+9) = 10 + 45 = 55
365
- expect(spy).toHaveBeenCalledWith(55);
366
- });
367
- });
368
-
369
- // ---------------------------------------------------------------------------
370
- // Untracked nesting
371
- // ---------------------------------------------------------------------------
372
-
373
- describe("untracked nesting", () => {
374
- it("nested untracked restores tracking correctly", () => {
375
- const a = signal(1);
376
- const b = signal(2);
377
- const c = signal(3);
378
- const spy = vi.fn();
379
- effect(() => {
380
- const av = a.value; // tracked
381
- const bv = untracked(() => {
382
- const cv = untracked(() => c.value); // not tracked
383
- return b.value + cv; // not tracked
384
- });
385
- spy(av + bv);
386
- return undefined;
387
- });
388
- spy.mockClear();
389
- b.value = 20; // not tracked
390
- expect(spy).not.toHaveBeenCalled();
391
- c.value = 30; // not tracked
392
- expect(spy).not.toHaveBeenCalled();
393
- a.value = 10; // tracked
394
- expect(spy).toHaveBeenCalledWith(10 + 20 + 30);
395
- });
396
-
397
- it("untracked inside computed does not track those reads", () => {
398
- const a = signal(1);
399
- const b = signal(2);
400
- const c = computed(() => a.value + untracked(() => b.value));
401
- const spy = vi.fn();
402
- effect(() => {
403
- spy(c.value);
404
- return undefined;
405
- });
406
- expect(spy).toHaveBeenCalledWith(3);
407
- spy.mockClear();
408
- b.value = 99; // not tracked by computed
409
- expect(spy).not.toHaveBeenCalled();
410
- a.value = 10;
411
- expect(spy).toHaveBeenCalledWith(109); // 10 + 99
412
- });
413
- });
414
-
415
- // ---------------------------------------------------------------------------
416
- // Reconcile edge cases
417
- // ---------------------------------------------------------------------------
418
-
419
- describe("reconcile edge cases", () => {
420
- it("duplicate keys: last one wins", () => {
421
- const parent = document.createElement("div");
422
- // Two items with same key - should not crash
423
- reconcile(
424
- parent,
425
- [
426
- { id: "a", v: 1 },
427
- { id: "a", v: 2 },
428
- ],
429
- {
430
- key: (i) => i.id,
431
- mount: (i) => {
432
- const el = document.createElement("span");
433
- el.textContent = String(i.v);
434
- return el;
435
- },
436
- },
437
- );
438
- // Should have 2 elements (both mounted since key collision means second is new)
439
- expect(parent.children.length).toBe(2);
440
- });
441
-
442
- it("null/undefined children in patch are skipped", () => {
443
- const parent = document.createElement("div");
444
- patch(parent, null, undefined, "hello", null);
445
- expect(parent.textContent).toBe("hello");
446
- expect(parent.childNodes.length).toBe(1);
447
- });
448
-
449
- it("text vs element swap", () => {
450
- const parent = document.createElement("div");
451
- parent.appendChild(document.createTextNode("old text"));
452
- const newEl = document.createElement("span");
453
- newEl.textContent = "new element";
454
- patch(parent, newEl);
455
- expect(parent.children.length).toBe(1);
456
- expect(parent.children[0]!.tagName).toBe("SPAN");
457
- });
458
-
459
- it("element vs text swap", () => {
460
- const parent = document.createElement("div");
461
- parent.appendChild(document.createElement("span"));
462
- patch(parent, "just text");
463
- expect(parent.childNodes.length).toBe(1);
464
- expect(parent.childNodes[0]!.nodeType).toBe(3); // text node
465
- expect(parent.textContent).toBe("just text");
466
- });
467
-
468
- it("keyed reorder preserves identity", () => {
469
- const parent = document.createElement("div");
470
- const a = document.createElement("div");
471
- a.setAttribute("data-id", "a");
472
- a.textContent = "A";
473
- const b = document.createElement("div");
474
- b.setAttribute("data-id", "b");
475
- b.textContent = "B";
476
- const c = document.createElement("div");
477
- c.setAttribute("data-id", "c");
478
- c.textContent = "C";
479
- parent.appendChild(a);
480
- parent.appendChild(b);
481
- parent.appendChild(c);
482
-
483
- // Reorder: c, a, b
484
- const newC = document.createElement("div");
485
- newC.setAttribute("data-id", "c");
486
- newC.textContent = "C!";
487
- const newA = document.createElement("div");
488
- newA.setAttribute("data-id", "a");
489
- newA.textContent = "A!";
490
- const newB = document.createElement("div");
491
- newB.setAttribute("data-id", "b");
492
- newB.textContent = "B!";
493
- reconcileChildren(parent, [newC, newA, newB]);
494
-
495
- // Identity preserved
496
- expect(parent.childNodes[0]).toBe(c);
497
- expect(parent.childNodes[1]).toBe(a);
498
- expect(parent.childNodes[2]).toBe(b);
499
- // Content patched
500
- expect(c.textContent).toBe("C!");
501
- expect(a.textContent).toBe("A!");
502
- expect(b.textContent).toBe("B!");
503
- });
504
-
505
- it("DocumentFragment children are flattened in patch", () => {
506
- const parent = document.createElement("div");
507
- const frag = document.createDocumentFragment();
508
- frag.appendChild(document.createTextNode("one"));
509
- frag.appendChild(document.createTextNode("two"));
510
- patch(parent, frag);
511
- expect(parent.childNodes.length).toBe(2);
512
- expect(parent.textContent).toBe("onetwo");
513
- });
514
- });
515
-
516
- // ---------------------------------------------------------------------------
517
- // Store edge cases
518
- // ---------------------------------------------------------------------------
519
-
520
- describe("store edge cases", () => {
521
- it("subscriber error does not prevent other subscribers", () => {
522
- const { set, subscribe: sub } = createStore<{ x: number }>();
523
- const spy1 = vi.fn(() => {
524
- throw new Error("sub1 error");
525
- });
526
- const spy2 = vi.fn();
527
- sub("x", spy1);
528
- sub("x", spy2);
529
- // Should not throw, and spy2 should still be called
530
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
531
- /* noop */
532
- });
533
- set("x", 1);
534
- expect(spy2).toHaveBeenCalledWith(1);
535
- consoleSpy.mockRestore();
536
- });
537
-
538
- it("batch exception does not leave batchDepth dirty", () => {
539
- const store = createStore<{ x: number }>();
540
- const spy = vi.fn();
541
- store.subscribe("x", spy);
542
- try {
543
- store.batch(() => {
544
- throw new Error("batch err");
545
- });
546
- } catch {
547
- // swallow
548
- }
549
- store.set("x", 42);
550
- expect(spy).toHaveBeenCalledWith(42);
551
- });
552
- });