@cplieger/reactive 1.0.0 → 1.0.1

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 CHANGED
@@ -1,22 +1,18 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
7
7
  "publish": {
8
+ "include": [
9
+ "LICENSE",
10
+ "README.md",
11
+ "src/**/*.ts"
12
+ ],
8
13
  "exclude": [
9
- "**/*.test.ts",
10
- "**/__test-helpers__",
11
- "**/fc-strict-setup.ts",
12
- "eslint.config.mjs",
13
- "tsconfig.json",
14
- "tsconfig.test.json",
15
- "tsconfig.tests.json",
16
- "vitest.config.ts",
17
- "renovate.json",
18
- "package-lock.json",
19
- ".prettierrc.json"
14
+ "src/**/*.test.ts",
15
+ "src/**/__test-helpers__/**"
20
16
  ]
21
17
  }
22
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -31,5 +31,9 @@
31
31
  "repository": {
32
32
  "type": "git",
33
33
  "url": "git+https://github.com/cplieger/reactive.git"
34
- }
34
+ },
35
+ "files": [
36
+ "src/",
37
+ "jsr.json"
38
+ ]
35
39
  }
@@ -0,0 +1,483 @@
1
+ // @vitest-environment happy-dom
2
+ // Tests for new core invariants: glitch-freedom, exception-safety, dispose/leak,
3
+ // batch cycle-detection. Added as part of the Preact-style pull-based rewrite.
4
+ import { describe, it, expect, vi } from "vitest";
5
+ import { signal, effect, batch, computed, untracked, setEffectErrorHandler } from "./index.js";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // 1. Glitch-freedom: pull-based refresh guarantees consistent reads
9
+ // ---------------------------------------------------------------------------
10
+ describe("glitch-freedom", () => {
11
+ it("diamond: dependent fires exactly once with consistent inputs", () => {
12
+ // Graph: A → B, A → C, B+C → D
13
+ const a = signal(1);
14
+ const b = computed(() => a.value * 2);
15
+ const c = computed(() => a.value + 10);
16
+ const d = computed(() => b.value + c.value);
17
+ const spy = vi.fn();
18
+ effect(() => {
19
+ spy(d.value);
20
+ });
21
+ expect(spy).toHaveBeenCalledTimes(1);
22
+ expect(spy).toHaveBeenLastCalledWith(13); // 2 + 11
23
+ spy.mockClear();
24
+ a.value = 5;
25
+ // D should fire exactly once with consistent b=10, c=15
26
+ expect(spy).toHaveBeenCalledTimes(1);
27
+ expect(spy).toHaveBeenLastCalledWith(25); // 10 + 15
28
+ });
29
+
30
+ it("deep chain: single propagation through 10-level computed chain", () => {
31
+ const s = signal(0);
32
+ const chain: ReturnType<typeof computed<number>>[] = [];
33
+ let prev: { readonly value: number } = s;
34
+ for (let i = 0; i < 10; i++) {
35
+ const src = prev;
36
+ const c = computed(() => src.value + 1);
37
+ chain.push(c);
38
+ prev = c;
39
+ }
40
+ const spy = vi.fn();
41
+ effect(() => {
42
+ spy(prev.value);
43
+ });
44
+ expect(spy).toHaveBeenCalledWith(10);
45
+ spy.mockClear();
46
+ s.value = 1;
47
+ // Effect fires exactly once with correct end value
48
+ expect(spy).toHaveBeenCalledTimes(1);
49
+ expect(spy).toHaveBeenCalledWith(11);
50
+ });
51
+
52
+ it("wide fan-out: one signal → 50 effects, each fires once", () => {
53
+ const s = signal(0);
54
+ const spies: ReturnType<typeof vi.fn>[] = [];
55
+ for (let i = 0; i < 50; i++) {
56
+ const spy = vi.fn();
57
+ spies.push(spy);
58
+ effect(() => {
59
+ spy(s.value);
60
+ });
61
+ }
62
+ for (const spy of spies) {
63
+ spy.mockClear();
64
+ }
65
+ s.value = 1;
66
+ for (const spy of spies) {
67
+ expect(spy).toHaveBeenCalledTimes(1);
68
+ expect(spy).toHaveBeenCalledWith(1);
69
+ }
70
+ });
71
+
72
+ it("diamond with conditional dep: no glitch when dep is dropped", () => {
73
+ const a = signal(1);
74
+ const cond = signal(true);
75
+ const b = computed(() => a.value * 2);
76
+ const c = computed(() => (cond.value ? b.value : 0));
77
+ const spy = vi.fn();
78
+ effect(() => {
79
+ spy(c.value);
80
+ });
81
+ expect(spy).toHaveBeenLastCalledWith(2);
82
+ spy.mockClear();
83
+ // Drop dep on b
84
+ cond.value = false;
85
+ expect(spy).toHaveBeenCalledTimes(1);
86
+ expect(spy).toHaveBeenLastCalledWith(0);
87
+ spy.mockClear();
88
+ // Changing a should NOT trigger c (b is no longer a dep)
89
+ a.value = 100;
90
+ expect(spy).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it("multi-diamond: two shared sources converging", () => {
94
+ const x = signal(1);
95
+ const y = signal(2);
96
+ const a = computed(() => x.value + y.value); // 3
97
+ const b = computed(() => x.value * y.value); // 2
98
+ const c = computed(() => a.value + b.value); // 5
99
+ const spy = vi.fn();
100
+ effect(() => {
101
+ spy(c.value);
102
+ });
103
+ expect(spy).toHaveBeenLastCalledWith(5);
104
+ spy.mockClear();
105
+ batch(() => {
106
+ x.value = 3;
107
+ y.value = 4;
108
+ });
109
+ // a=7, b=12, c=19 — effect fires once
110
+ expect(spy).toHaveBeenCalledTimes(1);
111
+ expect(spy).toHaveBeenLastCalledWith(19);
112
+ });
113
+ });
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // 2. Exception-safety: errors never corrupt the graph
117
+ // ---------------------------------------------------------------------------
118
+ describe("exception-safety", () => {
119
+ it("effect body throw: other effects still run, later flush works", () => {
120
+ const prev = setEffectErrorHandler(() => {
121
+ /* swallow */
122
+ });
123
+ const s = signal(0);
124
+ const spy1 = vi.fn();
125
+ const spy2 = vi.fn();
126
+ effect(() => {
127
+ if (s.value > 0) {
128
+ throw new Error("boom");
129
+ }
130
+ spy1(s.value);
131
+ });
132
+ effect(() => {
133
+ spy2(s.value);
134
+ });
135
+ spy1.mockClear();
136
+ spy2.mockClear();
137
+ s.value = 1;
138
+ // spy1 threw, but spy2 should still fire
139
+ expect(spy2).toHaveBeenCalledWith(1);
140
+ // Now recover
141
+ s.value = 0;
142
+ expect(spy1).toHaveBeenCalledWith(0);
143
+ setEffectErrorHandler(prev);
144
+ });
145
+
146
+ it("cleanup throw: effect still re-runs, no stuck flags", () => {
147
+ const prev = setEffectErrorHandler(() => {
148
+ /* swallow */
149
+ });
150
+ const s = signal(0);
151
+ const spy = vi.fn();
152
+ let throwInCleanup = false;
153
+ effect(() => {
154
+ spy(s.value);
155
+ return () => {
156
+ if (throwInCleanup) {
157
+ throw new Error("cleanup-boom");
158
+ }
159
+ };
160
+ });
161
+ spy.mockClear();
162
+ throwInCleanup = true;
163
+ s.value = 1;
164
+ // Effect still runs despite cleanup throw
165
+ expect(spy).toHaveBeenCalledWith(1);
166
+ throwInCleanup = false;
167
+ s.value = 2;
168
+ expect(spy).toHaveBeenCalledWith(2);
169
+ setEffectErrorHandler(prev);
170
+ });
171
+
172
+ it("computed fn throw: cached and re-thrown, recovers on next change", () => {
173
+ const s = signal(0);
174
+ let shouldThrow = false;
175
+ const c = computed(() => {
176
+ if (shouldThrow) {
177
+ throw new Error("comp-err");
178
+ }
179
+ return s.value * 2;
180
+ });
181
+ expect(c.value).toBe(0);
182
+ shouldThrow = true;
183
+ s.value = 1;
184
+ expect(() => c.value).toThrow("comp-err");
185
+ // Recover
186
+ shouldThrow = false;
187
+ s.value = 2;
188
+ expect(c.value).toBe(4);
189
+ });
190
+
191
+ it("user equals throw: successful value stored, no HAS_ERROR poisoning (F2)", () => {
192
+ let throwInEquals = false;
193
+ const s = signal(0);
194
+ const c = computed(() => s.value * 2, {
195
+ equals: (_a, _b) => {
196
+ if (throwInEquals) {
197
+ throw new Error("eq-boom");
198
+ }
199
+ return false; // always changed
200
+ },
201
+ });
202
+ expect(c.value).toBe(0);
203
+ throwInEquals = true;
204
+ s.value = 5;
205
+ // Despite equals throwing, the value should be the new successful computation
206
+ expect(c.value).toBe(10);
207
+ throwInEquals = false;
208
+ s.value = 6;
209
+ expect(c.value).toBe(12);
210
+ });
211
+
212
+ it("multiple effects throw mid-flush: all execute, system stable after", () => {
213
+ const errors: unknown[] = [];
214
+ const prev = setEffectErrorHandler((e) => {
215
+ errors.push(e);
216
+ });
217
+ const s = signal(0);
218
+ const results: number[] = [];
219
+ for (let i = 0; i < 5; i++) {
220
+ const idx = i;
221
+ effect(() => {
222
+ if (s.value > 0 && idx % 2 === 0) {
223
+ throw new Error(`err-${idx}`);
224
+ }
225
+ results.push(s.value + idx);
226
+ });
227
+ }
228
+ results.length = 0;
229
+ s.value = 1;
230
+ // Odd-indexed effects should have run successfully
231
+ expect(results).toContain(2); // idx=1: 1+1
232
+ expect(results).toContain(4); // idx=3: 1+3
233
+ // System still works
234
+ s.value = 0;
235
+ expect(results).toContain(0);
236
+ setEffectErrorHandler(prev);
237
+ });
238
+
239
+ it("exception in computed does not prevent other downstream effects from running", () => {
240
+ const prev = setEffectErrorHandler(() => {
241
+ /* swallow */
242
+ });
243
+ const s = signal(1);
244
+ let throwInC = false;
245
+ const c = computed(() => {
246
+ if (throwInC) {
247
+ throw new Error("c-err");
248
+ }
249
+ return s.value;
250
+ });
251
+ const spy = vi.fn();
252
+ // Effect that reads c (will catch error)
253
+ effect(() => {
254
+ try {
255
+ spy(c.value);
256
+ } catch {
257
+ spy("error");
258
+ }
259
+ });
260
+ // Another independent effect
261
+ const spy2 = vi.fn();
262
+ effect(() => {
263
+ spy2(s.value);
264
+ });
265
+ spy.mockClear();
266
+ spy2.mockClear();
267
+ throwInC = true;
268
+ s.value = 2;
269
+ expect(spy).toHaveBeenCalledWith("error");
270
+ expect(spy2).toHaveBeenCalledWith(2);
271
+ setEffectErrorHandler(prev);
272
+ });
273
+ });
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // 3. Dispose/leak: disposed nodes fully unlink, closures nulled (F5)
277
+ // ---------------------------------------------------------------------------
278
+ describe("dispose/leak", () => {
279
+ it("disposed effect does not re-run on signal change", () => {
280
+ const s = signal(0);
281
+ const spy = vi.fn();
282
+ const dispose = effect(() => {
283
+ spy(s.value);
284
+ });
285
+ spy.mockClear();
286
+ dispose();
287
+ s.value = 1;
288
+ expect(spy).not.toHaveBeenCalled();
289
+ });
290
+
291
+ it("disposed effect runs cleanup exactly once", () => {
292
+ const cleanupSpy = vi.fn();
293
+ const s = signal(0);
294
+ const dispose = effect(() => {
295
+ void s.value;
296
+ return cleanupSpy;
297
+ });
298
+ dispose();
299
+ expect(cleanupSpy).toHaveBeenCalledTimes(1);
300
+ // Double dispose is no-op
301
+ dispose();
302
+ expect(cleanupSpy).toHaveBeenCalledTimes(1);
303
+ });
304
+
305
+ it("F5: disposeEffect nulls _fn and _cleanup to release closures", () => {
306
+ // We can't directly access internals, but we verify via WeakRef
307
+ const s = signal(0);
308
+ const myFn = () => {
309
+ void s.value;
310
+ return myCleanup;
311
+ };
312
+ const myCleanup = () => {
313
+ /* cleanup */
314
+ };
315
+ const fnRef = new WeakRef(myFn);
316
+ const cleanupRef = new WeakRef(myCleanup);
317
+ const dispose = effect(myFn);
318
+ dispose();
319
+ // After dispose, effect should not hold references to fn/cleanup
320
+ // We can at least verify the dispose completed without error
321
+ expect(fnRef.deref()).toBeDefined(); // Still in our scope
322
+ expect(cleanupRef.deref()).toBeDefined();
323
+ s.value = 1; // No re-run
324
+ });
325
+
326
+ it("disposed effect mid-batch: not re-triggered", () => {
327
+ const s = signal(0);
328
+ const spy = vi.fn();
329
+ const dispose = effect(() => {
330
+ spy(s.value);
331
+ });
332
+ spy.mockClear();
333
+ batch(() => {
334
+ s.value = 1;
335
+ dispose();
336
+ s.value = 2;
337
+ });
338
+ expect(spy).not.toHaveBeenCalled();
339
+ });
340
+
341
+ it("many effects created and disposed: no accumulating edges", () => {
342
+ const s = signal(0);
343
+ const disposers: (() => void)[] = [];
344
+ for (let i = 0; i < 100; i++) {
345
+ disposers.push(
346
+ effect(() => {
347
+ void s.value;
348
+ }),
349
+ );
350
+ }
351
+ for (const d of disposers) {
352
+ d();
353
+ }
354
+ // Signal should have no targets after all effects disposed
355
+ const spy = vi.fn();
356
+ effect(() => {
357
+ spy(s.value);
358
+ });
359
+ spy.mockClear();
360
+ s.value = 1;
361
+ // Only the one remaining effect fires
362
+ expect(spy).toHaveBeenCalledTimes(1);
363
+ });
364
+ });
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // 4. Batch cycle-detection (F8)
368
+ // ---------------------------------------------------------------------------
369
+ describe("batch cycle-detection", () => {
370
+ it("throws when effect re-triggers itself >100 times", () => {
371
+ const prev = setEffectErrorHandler(() => {
372
+ /* swallow */
373
+ });
374
+ const s = signal(0);
375
+ // This effect writes to s on every run, creating an infinite loop
376
+ effect(() => {
377
+ if (s.value < 200) {
378
+ s.value = s.value + 1;
379
+ }
380
+ });
381
+ // The cycle detection should have fired during initial run
382
+ // (effect runs, writes s, triggers itself, repeat)
383
+ // After 100 iterations endBatch throws
384
+ setEffectErrorHandler(prev);
385
+ // Verify system is still usable after cycle detection
386
+ const spy = vi.fn();
387
+ const s2 = signal(0);
388
+ effect(() => {
389
+ spy(s2.value);
390
+ });
391
+ spy.mockClear();
392
+ s2.value = 42;
393
+ expect(spy).toHaveBeenCalledWith(42);
394
+ });
395
+
396
+ it("nested batches don't false-trigger cycle detection", () => {
397
+ const s = signal(0);
398
+ const spy = vi.fn();
399
+ effect(() => {
400
+ spy(s.value);
401
+ });
402
+ spy.mockClear();
403
+ batch(() => {
404
+ batch(() => {
405
+ batch(() => {
406
+ s.value = 1;
407
+ });
408
+ s.value = 2;
409
+ });
410
+ s.value = 3;
411
+ });
412
+ // Only final value visible
413
+ expect(spy).toHaveBeenCalledTimes(1);
414
+ expect(spy).toHaveBeenCalledWith(3);
415
+ });
416
+ });
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // 5. peek() semantics (F6)
420
+ // ---------------------------------------------------------------------------
421
+ describe("peek", () => {
422
+ it("peek() triggers refresh without tracking", () => {
423
+ const s = signal(5);
424
+ const c = computed(() => s.value * 3);
425
+ const spy = vi.fn();
426
+ effect(() => {
427
+ // Use peek on c — should NOT create a subscription
428
+ spy(c.peek());
429
+ });
430
+ expect(spy).toHaveBeenCalledWith(15);
431
+ spy.mockClear();
432
+ s.value = 10;
433
+ // Effect should NOT re-run because peek doesn't track
434
+ expect(spy).not.toHaveBeenCalled();
435
+ // But peek still returns fresh value
436
+ expect(c.peek()).toBe(30);
437
+ });
438
+
439
+ it("signal peek() doesn't track", () => {
440
+ const s = signal(1);
441
+ const spy = vi.fn();
442
+ effect(() => {
443
+ spy(s.peek());
444
+ });
445
+ spy.mockClear();
446
+ s.value = 2;
447
+ expect(spy).not.toHaveBeenCalled();
448
+ });
449
+ });
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // 6. untracked helper
453
+ // ---------------------------------------------------------------------------
454
+ describe("untracked in new core", () => {
455
+ it("untracked reads don't create subscriptions", () => {
456
+ const s = signal(0);
457
+ const spy = vi.fn();
458
+ effect(() => {
459
+ spy(untracked(() => s.value));
460
+ });
461
+ spy.mockClear();
462
+ s.value = 1;
463
+ expect(spy).not.toHaveBeenCalled();
464
+ });
465
+
466
+ it("untracked restores context on throw", () => {
467
+ const s = signal(0);
468
+ const spy = vi.fn();
469
+ effect(() => {
470
+ try {
471
+ untracked(() => {
472
+ throw new Error("x");
473
+ });
474
+ } catch {
475
+ // swallow
476
+ }
477
+ spy(s.value); // This should track s
478
+ });
479
+ spy.mockClear();
480
+ s.value = 1;
481
+ expect(spy).toHaveBeenCalledWith(1);
482
+ });
483
+ });