@esportsplus/reactivity 0.31.3 → 0.33.0
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/README.md +43 -6
- package/bench/cellx.bench.ts +61 -0
- package/bench/dynamic.bench.ts +103 -0
- package/bench/kairo.bench.ts +368 -0
- package/bench/lib/reactive-adapter.ts +51 -0
- package/bench/molbench.bench.ts +75 -0
- package/{tests/bench/system.ts → bench/system.bench.ts} +3 -3
- package/build/compiler/array.d.ts +1 -1
- package/build/compiler/constants.d.ts +7 -6
- package/build/compiler/constants.js +6 -7
- package/build/compiler/object.d.ts +1 -1
- package/build/compiler/object.js +1 -2
- package/build/compiler/primitives.d.ts +1 -1
- package/build/constants.d.ts +3 -1
- package/build/constants.js +3 -1
- package/build/reactive/array.d.ts +7 -3
- package/build/reactive/array.js +32 -24
- package/build/reactive/index.d.ts +2 -2
- package/build/reactive/index.js +1 -1
- package/build/reactive/object.d.ts +4 -4
- package/build/reactive/object.js +8 -23
- package/build/system.d.ts +15 -6
- package/build/system.js +432 -69
- package/build/types.d.ts +22 -4
- package/package.json +10 -7
- package/src/compiler/array.ts +1 -1
- package/src/compiler/constants.ts +8 -6
- package/src/compiler/index.ts +2 -1
- package/src/compiler/object.ts +2 -2
- package/src/compiler/plugins/vite.ts +1 -0
- package/src/compiler/primitives.ts +1 -1
- package/src/constants.ts +6 -2
- package/src/reactive/array.ts +50 -35
- package/src/reactive/index.ts +6 -6
- package/src/reactive/object.ts +16 -41
- package/src/system.ts +635 -80
- package/src/types.ts +38 -9
- package/test/async-computed.test.ts +409 -0
- package/test/async-errors.test.ts +146 -0
- package/test/async-hardening.test.ts +73 -0
- package/test/async-iterable.test.ts +221 -0
- package/test/async-nested.test.ts +19 -0
- package/test/deep-graphs.test.ts +215 -0
- package/{tests/effects.ts → test/effects.test.ts} +122 -0
- package/test/equals.test.ts +142 -0
- package/test/errors.test.ts +201 -0
- package/test/flush.test.ts +185 -0
- package/test/glitch-freedom.test.ts +115 -0
- package/test/invalidate.test.ts +147 -0
- package/test/lib/wait-for.ts +28 -0
- package/test/pending-writes.test.ts +139 -0
- package/{tests/reactive.ts → test/reactive/reactive.test.ts} +20 -19
- package/test/read-dedup.test.ts +120 -0
- package/test/signal-selector.test.ts +187 -0
- package/{tests/system.ts → test/system.test.ts} +210 -19
- package/test/tsconfig.json +16 -0
- package/test/untrack.test.ts +146 -0
- package/vitest.config.ts +2 -2
- package/tests/async-computed.ts +0 -239
- /package/{tests/bench/array.ts → bench/reactive/array.bench.ts} +0 -0
- /package/{tests/bench/reactive-object.ts → bench/reactive/reactive-object.bench.ts} +0 -0
- /package/{tests → bench}/tsconfig.json +0 -0
- /package/{tests/compiler.ts → test/compiler/compiler.test.ts} +0 -0
- /package/{tests/primitives.ts → test/primitives.test.ts} +0 -0
- /package/{tests/array.ts → test/reactive/array.test.ts} +0 -0
- /package/{tests/nested.ts → test/reactive/nested.test.ts} +0 -0
- /package/{tests/objects.ts → test/reactive/objects.test.ts} +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, effect, onCleanup, read, signal, write } from '~/system';
|
|
3
|
+
|
|
4
|
+
// signal.selector(node, key) rides a Map for entry lookup — SameValueZero semantics: NaN keys match
|
|
5
|
+
// themselves and ±0 collapse, slightly wider than the settled === for those two cases. Object keys
|
|
6
|
+
// compare by reference and MUST be stable (a re-allocated key object never matches). The initial
|
|
7
|
+
// node.value === key snapshot uses ===.
|
|
8
|
+
|
|
9
|
+
const settle = () => Promise.resolve().then(() => Promise.resolve());
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
describe('signal.selector per-key selector', () => {
|
|
13
|
+
it('re-runs on transitions into/out of the key, not on writes between other keys', async () => {
|
|
14
|
+
let runs = 0,
|
|
15
|
+
s = signal('a');
|
|
16
|
+
|
|
17
|
+
effect(() => {
|
|
18
|
+
runs++;
|
|
19
|
+
signal.selector(s, 'b');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
runs = 0;
|
|
23
|
+
|
|
24
|
+
write(s, 'c');
|
|
25
|
+
await settle();
|
|
26
|
+
expect(runs).toBe(0);
|
|
27
|
+
|
|
28
|
+
write(s, 'b');
|
|
29
|
+
await settle();
|
|
30
|
+
expect(runs).toBe(1);
|
|
31
|
+
|
|
32
|
+
write(s, 'd');
|
|
33
|
+
await settle();
|
|
34
|
+
expect(runs).toBe(2);
|
|
35
|
+
|
|
36
|
+
write(s, 'e');
|
|
37
|
+
await settle();
|
|
38
|
+
expect(runs).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns the correct boolean at first call, tracked and untracked', () => {
|
|
42
|
+
let s = signal('x'),
|
|
43
|
+
tracked: boolean | null = null;
|
|
44
|
+
|
|
45
|
+
expect(signal.selector(s, 'x')).toBe(true);
|
|
46
|
+
expect(signal.selector(s, 'y')).toBe(false);
|
|
47
|
+
|
|
48
|
+
effect(() => {
|
|
49
|
+
tracked = signal.selector(s, 'x');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(tracked).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('is lazy: keys stays null until a tracked call, one entry per distinct key', () => {
|
|
56
|
+
let s = signal(0);
|
|
57
|
+
|
|
58
|
+
expect(s.keys).toBe(null);
|
|
59
|
+
|
|
60
|
+
signal.selector(s, 1);
|
|
61
|
+
expect(s.keys).toBe(null);
|
|
62
|
+
|
|
63
|
+
effect(() => {
|
|
64
|
+
signal.selector(s, 1);
|
|
65
|
+
signal.selector(s, 2);
|
|
66
|
+
signal.selector(s, 1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(s.keys).not.toBe(null);
|
|
70
|
+
expect(s.keys!.size).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('evicts entries on last unsubscribe and nulls keys when the map empties', () => {
|
|
74
|
+
let s = signal(0),
|
|
75
|
+
stop1 = effect(() => {
|
|
76
|
+
signal.selector(s, 1);
|
|
77
|
+
}),
|
|
78
|
+
stop2 = effect(() => {
|
|
79
|
+
signal.selector(s, 2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(s.keys!.size).toBe(2);
|
|
83
|
+
|
|
84
|
+
stop1();
|
|
85
|
+
expect(s.keys!.size).toBe(1);
|
|
86
|
+
|
|
87
|
+
stop2();
|
|
88
|
+
expect(s.keys).toBe(null);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('leaves the computed last-unsub auto-dispose behavior unchanged', () => {
|
|
92
|
+
let cleaned = 0,
|
|
93
|
+
s = signal(0);
|
|
94
|
+
|
|
95
|
+
let stop = effect(() => {
|
|
96
|
+
let c = computed(() => {
|
|
97
|
+
onCleanup(() => {
|
|
98
|
+
cleaned++;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return read(s);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
read(c);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(cleaned).toBe(0);
|
|
108
|
+
|
|
109
|
+
// Disposing the effect unlinks c's last subscriber → c still auto-disposes and its cleanup fires.
|
|
110
|
+
stop();
|
|
111
|
+
expect(cleaned).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('fans out keys independently; a write touches only the prev and next entries', async () => {
|
|
115
|
+
let s = signal('a'),
|
|
116
|
+
runs1 = 0,
|
|
117
|
+
runs2 = 0,
|
|
118
|
+
runs3 = 0;
|
|
119
|
+
|
|
120
|
+
effect(() => {
|
|
121
|
+
runs1++;
|
|
122
|
+
signal.selector(s, 'a');
|
|
123
|
+
});
|
|
124
|
+
effect(() => {
|
|
125
|
+
runs2++;
|
|
126
|
+
signal.selector(s, 'b');
|
|
127
|
+
});
|
|
128
|
+
effect(() => {
|
|
129
|
+
runs3++;
|
|
130
|
+
signal.selector(s, 'c');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
runs1 = runs2 = runs3 = 0;
|
|
134
|
+
|
|
135
|
+
write(s, 'b');
|
|
136
|
+
await settle();
|
|
137
|
+
|
|
138
|
+
expect(runs1).toBe(1);
|
|
139
|
+
expect(runs2).toBe(1);
|
|
140
|
+
expect(runs3).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('NaN key: the Map reuses one stable entry and transitions fan out (SameValueZero caveat)', async () => {
|
|
144
|
+
let observed: boolean[] = [],
|
|
145
|
+
runs = 0,
|
|
146
|
+
s = signal<number>(0);
|
|
147
|
+
|
|
148
|
+
effect(() => {
|
|
149
|
+
runs++;
|
|
150
|
+
observed.push(signal.selector(s, NaN));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
runs = 0;
|
|
154
|
+
observed.length = 0;
|
|
155
|
+
|
|
156
|
+
expect(s.keys!.size).toBe(1);
|
|
157
|
+
|
|
158
|
+
write(s, NaN);
|
|
159
|
+
await settle();
|
|
160
|
+
|
|
161
|
+
expect(runs).toBe(1);
|
|
162
|
+
expect(observed[observed.length - 1]).toBe(true);
|
|
163
|
+
expect(s.keys!.size).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('object key compares by reference: a stable key matches, a re-allocated one does not', async () => {
|
|
167
|
+
let k = { id: 1 },
|
|
168
|
+
observed: boolean[] = [],
|
|
169
|
+
s = signal<object>(k);
|
|
170
|
+
|
|
171
|
+
effect(() => {
|
|
172
|
+
observed.push(signal.selector(s, k));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(observed[0]).toBe(true);
|
|
176
|
+
|
|
177
|
+
observed.length = 0;
|
|
178
|
+
|
|
179
|
+
write(s, { id: 1 });
|
|
180
|
+
await settle();
|
|
181
|
+
expect(observed[observed.length - 1]).toBe(false);
|
|
182
|
+
|
|
183
|
+
write(s, k);
|
|
184
|
+
await settle();
|
|
185
|
+
expect(observed[observed.length - 1]).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { computed, dispose, effect, isComputed, isSignal, onCleanup, read, root, signal, write } from '~/system';
|
|
3
|
+
import { waitFor } from './lib/wait-for';
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
describe('signal', () => {
|
|
@@ -424,7 +425,7 @@ describe('onCleanup', () => {
|
|
|
424
425
|
expect(fn).not.toHaveBeenCalled();
|
|
425
426
|
});
|
|
426
427
|
|
|
427
|
-
it('throwing cleanup
|
|
428
|
+
it('throwing cleanup still runs subsequent cleanups and rethrows to the dispose caller', () => {
|
|
428
429
|
let called: number[] = [],
|
|
429
430
|
c = computed((onCleanup) => {
|
|
430
431
|
onCleanup(() => { called.push(1); });
|
|
@@ -435,14 +436,89 @@ describe('onCleanup', () => {
|
|
|
435
436
|
|
|
436
437
|
expect(called).toEqual([]);
|
|
437
438
|
|
|
438
|
-
// dispose() calls cleanup() synchronously — no try/catch wraps it
|
|
439
|
-
// cleanup iterates the array: index 0 (push 1), index 1 (push 2, throws)
|
|
440
|
-
// index 2 (push 3) is never reached because no try/catch in cleanup()
|
|
441
439
|
expect(() => dispose(c)).toThrow('cleanup boom');
|
|
442
440
|
|
|
443
|
-
|
|
444
|
-
expect(called).
|
|
445
|
-
expect(
|
|
441
|
+
// Every disposer ran despite the throw
|
|
442
|
+
expect(called).toEqual([1, 2, 3]);
|
|
443
|
+
expect(c.cleanup).toBe(null);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('single throwing cleanup rethrows the exact error', () => {
|
|
447
|
+
let error = new Error('solo boom'),
|
|
448
|
+
c = computed((onCleanup) => {
|
|
449
|
+
onCleanup(() => { throw error; });
|
|
450
|
+
return 1;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
dispose(c);
|
|
455
|
+
expect.unreachable();
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
expect(e).toBe(error);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('multiple throwing cleanups aggregate into AggregateError with all captured', () => {
|
|
463
|
+
let called: number[] = [],
|
|
464
|
+
c = computed((onCleanup) => {
|
|
465
|
+
onCleanup(() => { called.push(1); throw new Error('first'); });
|
|
466
|
+
onCleanup(() => { called.push(2); throw new Error('second'); });
|
|
467
|
+
onCleanup(() => { called.push(3); });
|
|
468
|
+
return 1;
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
dispose(c);
|
|
473
|
+
expect.unreachable();
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
expect(e).toBeInstanceOf(AggregateError);
|
|
477
|
+
expect((e as AggregateError).errors.map((x) => (x as Error).message)).toEqual(['first', 'second']);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
expect(called).toEqual([1, 2, 3]);
|
|
481
|
+
expect(c.cleanup).toBe(null);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('throwing cleanup during recompute does not abort the stabilize pass', async () => {
|
|
485
|
+
let s = signal(0),
|
|
486
|
+
captured: unknown[] = [],
|
|
487
|
+
listeners = process.listeners('uncaughtException'),
|
|
488
|
+
otherValues: number[] = [],
|
|
489
|
+
values: number[] = [];
|
|
490
|
+
|
|
491
|
+
effect(() => {
|
|
492
|
+
values.push(read(s));
|
|
493
|
+
onCleanup(() => { throw new Error('teardown boom'); });
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
effect(() => {
|
|
497
|
+
otherValues.push(read(s));
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
process.removeAllListeners('uncaughtException');
|
|
501
|
+
process.on('uncaughtException', (e) => { captured.push(e); });
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
write(s, 1);
|
|
505
|
+
await waitFor(() => captured.length === 1, 'teardown error surfaces as uncaught');
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
process.removeAllListeners('uncaughtException');
|
|
509
|
+
|
|
510
|
+
for (let i = 0, n = listeners.length; i < n; i++) {
|
|
511
|
+
process.on('uncaughtException', listeners[i]);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Both effects re-ran — the pass survived the teardown failure
|
|
516
|
+
expect(values).toEqual([0, 1]);
|
|
517
|
+
expect(otherValues).toEqual([0, 1]);
|
|
518
|
+
|
|
519
|
+
// And the teardown error surfaced as an uncaught microtask exception
|
|
520
|
+
expect(captured.length).toBe(1);
|
|
521
|
+
expect((captured[0] as Error).message).toBe('teardown boom');
|
|
446
522
|
});
|
|
447
523
|
});
|
|
448
524
|
|
|
@@ -610,10 +686,109 @@ describe('computed object size', () => {
|
|
|
610
686
|
expect('type' in c).toBe(false);
|
|
611
687
|
});
|
|
612
688
|
|
|
613
|
-
it('has
|
|
689
|
+
it('has no more own properties than 16 (14 slimmed + gv + rv)', () => {
|
|
614
690
|
let c = computed(() => 42);
|
|
615
691
|
|
|
616
|
-
expect(Object.keys(c).length).
|
|
692
|
+
expect(Object.keys(c).length).toBeLessThanOrEqual(16);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
describe('globalVersion fast path', () => {
|
|
697
|
+
it('a computed pulled by two tracked readers in one settle runs its fn at most once with correct values', async () => {
|
|
698
|
+
let cRuns = 0,
|
|
699
|
+
s = signal(0),
|
|
700
|
+
seen1: number[] = [],
|
|
701
|
+
seen2: number[] = [];
|
|
702
|
+
|
|
703
|
+
let c = computed(() => {
|
|
704
|
+
cRuns++;
|
|
705
|
+
|
|
706
|
+
return read(s) + 1;
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
effect(() => {
|
|
710
|
+
if (read(s) > 0) {
|
|
711
|
+
seen1.push(read(c));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
effect(() => {
|
|
716
|
+
if (read(s) > 0) {
|
|
717
|
+
seen2.push(read(c));
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
cRuns = 0;
|
|
722
|
+
|
|
723
|
+
write(s, 1);
|
|
724
|
+
await Promise.resolve();
|
|
725
|
+
await Promise.resolve();
|
|
726
|
+
|
|
727
|
+
expect(cRuns).toBe(1);
|
|
728
|
+
expect(seen1[seen1.length - 1]).toBe(2);
|
|
729
|
+
expect(seen2[seen2.length - 1]).toBe(2);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('write during recompute leaves the racing node validating normally (no stale skip)', async () => {
|
|
733
|
+
let s = signal(0),
|
|
734
|
+
t = signal(0);
|
|
735
|
+
|
|
736
|
+
let c = computed(() => {
|
|
737
|
+
let v = read(s);
|
|
738
|
+
|
|
739
|
+
if (v > 0) {
|
|
740
|
+
write(t, v * 10);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return v;
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
let d = computed(() => read(t) + read(c));
|
|
747
|
+
|
|
748
|
+
effect(() => {
|
|
749
|
+
read(d);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
write(s, 2);
|
|
753
|
+
await Promise.resolve();
|
|
754
|
+
await Promise.resolve();
|
|
755
|
+
await Promise.resolve();
|
|
756
|
+
|
|
757
|
+
expect(read(t)).toBe(20);
|
|
758
|
+
expect(read(c)).toBe(2);
|
|
759
|
+
expect(read(d)).toBe(22);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('a fresh signal write invalidates the fast path (no stale skip across a settle boundary)', async () => {
|
|
763
|
+
let cRuns = 0,
|
|
764
|
+
s = signal(1),
|
|
765
|
+
seen: number[] = [];
|
|
766
|
+
|
|
767
|
+
let c = computed(() => {
|
|
768
|
+
cRuns++;
|
|
769
|
+
|
|
770
|
+
return read(s) * 2;
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
effect(() => {
|
|
774
|
+
seen.push(read(c));
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
expect(seen[seen.length - 1]).toBe(2);
|
|
778
|
+
|
|
779
|
+
write(s, 5);
|
|
780
|
+
await Promise.resolve();
|
|
781
|
+
await Promise.resolve();
|
|
782
|
+
|
|
783
|
+
expect(read(c)).toBe(10);
|
|
784
|
+
expect(seen[seen.length - 1]).toBe(10);
|
|
785
|
+
|
|
786
|
+
write(s, 7);
|
|
787
|
+
await Promise.resolve();
|
|
788
|
+
await Promise.resolve();
|
|
789
|
+
|
|
790
|
+
expect(read(c)).toBe(14);
|
|
791
|
+
expect(seen[seen.length - 1]).toBe(14);
|
|
617
792
|
});
|
|
618
793
|
});
|
|
619
794
|
|
|
@@ -842,8 +1017,9 @@ describe('edge cases', () => {
|
|
|
842
1017
|
expect(cValues).toEqual([0, 30]);
|
|
843
1018
|
});
|
|
844
1019
|
|
|
845
|
-
it('computed that throws on update
|
|
1020
|
+
it('computed that throws on update caches the error and rethrows at read', async () => {
|
|
846
1021
|
let s = signal(0),
|
|
1022
|
+
effectErrors: unknown[] = [],
|
|
847
1023
|
effectValues: number[] = [],
|
|
848
1024
|
c = computed(() => {
|
|
849
1025
|
let val = read(s);
|
|
@@ -856,7 +1032,12 @@ describe('edge cases', () => {
|
|
|
856
1032
|
});
|
|
857
1033
|
|
|
858
1034
|
effect(() => {
|
|
859
|
-
|
|
1035
|
+
try {
|
|
1036
|
+
effectValues.push(read(c));
|
|
1037
|
+
}
|
|
1038
|
+
catch (e) {
|
|
1039
|
+
effectErrors.push(e);
|
|
1040
|
+
}
|
|
860
1041
|
});
|
|
861
1042
|
|
|
862
1043
|
expect(effectValues).toEqual([0]);
|
|
@@ -870,13 +1051,16 @@ describe('edge cases', () => {
|
|
|
870
1051
|
write(s, 2);
|
|
871
1052
|
await Promise.resolve();
|
|
872
1053
|
|
|
873
|
-
//
|
|
874
|
-
expect(read(c)).
|
|
1054
|
+
// Error propagates to the subscribed effect and rethrows at read
|
|
1055
|
+
expect(() => read(c)).toThrow('boom');
|
|
1056
|
+
expect(effectErrors.length).toBe(1);
|
|
1057
|
+
expect((effectErrors[0] as Error).message).toBe('boom');
|
|
875
1058
|
expect(effectValues).toEqual([0, 10]);
|
|
876
1059
|
});
|
|
877
1060
|
|
|
878
1061
|
it('computed alternates between throwing and succeeding', async () => {
|
|
879
1062
|
let s = signal(0),
|
|
1063
|
+
effectErrors: unknown[] = [],
|
|
880
1064
|
effectValues: number[] = [],
|
|
881
1065
|
c = computed(() => {
|
|
882
1066
|
let val = read(s);
|
|
@@ -889,7 +1073,12 @@ describe('edge cases', () => {
|
|
|
889
1073
|
});
|
|
890
1074
|
|
|
891
1075
|
effect(() => {
|
|
892
|
-
|
|
1076
|
+
try {
|
|
1077
|
+
effectValues.push(read(c));
|
|
1078
|
+
}
|
|
1079
|
+
catch (e) {
|
|
1080
|
+
effectErrors.push(e);
|
|
1081
|
+
}
|
|
893
1082
|
});
|
|
894
1083
|
|
|
895
1084
|
expect(effectValues).toEqual([0]);
|
|
@@ -897,22 +1086,24 @@ describe('edge cases', () => {
|
|
|
897
1086
|
write(s, 1);
|
|
898
1087
|
await Promise.resolve();
|
|
899
1088
|
|
|
900
|
-
// Threw on odd,
|
|
901
|
-
expect(read(c)).
|
|
1089
|
+
// Threw on odd, error cached and rethrown at read
|
|
1090
|
+
expect(() => read(c)).toThrow('odd');
|
|
1091
|
+
expect(effectErrors.length).toBe(1);
|
|
902
1092
|
expect(effectValues).toEqual([0]);
|
|
903
1093
|
|
|
904
1094
|
write(s, 2);
|
|
905
1095
|
await Promise.resolve();
|
|
906
1096
|
|
|
907
|
-
// Succeeds on even, value updates
|
|
1097
|
+
// Succeeds on even, error cleared, value updates
|
|
908
1098
|
expect(read(c)).toBe(2);
|
|
909
1099
|
expect(effectValues).toEqual([0, 2]);
|
|
910
1100
|
|
|
911
1101
|
write(s, 3);
|
|
912
1102
|
await Promise.resolve();
|
|
913
1103
|
|
|
914
|
-
// Threw on odd again
|
|
915
|
-
expect(read(c)).
|
|
1104
|
+
// Threw on odd again — the error caches on the Nth failure too
|
|
1105
|
+
expect(() => read(c)).toThrow('odd');
|
|
1106
|
+
expect(effectErrors.length).toBe(2);
|
|
916
1107
|
expect(effectValues).toEqual([0, 2]);
|
|
917
1108
|
|
|
918
1109
|
write(s, 4);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, effect, peek, read, signal, untrack, write } from '~/system';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('untrack', () => {
|
|
6
|
+
it('does not re-run an effect for a signal read only inside untrack, and sees current values', async () => {
|
|
7
|
+
let a = signal(1),
|
|
8
|
+
b = signal(10),
|
|
9
|
+
calls = 0,
|
|
10
|
+
seenB: number[] = [];
|
|
11
|
+
|
|
12
|
+
effect(() => {
|
|
13
|
+
read(a);
|
|
14
|
+
seenB.push(untrack(() => read(b)));
|
|
15
|
+
calls++;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(calls).toBe(1);
|
|
19
|
+
expect(seenB).toEqual([10]);
|
|
20
|
+
|
|
21
|
+
write(b, 20);
|
|
22
|
+
await Promise.resolve();
|
|
23
|
+
|
|
24
|
+
// b changed but was only ever read inside untrack — the effect must not re-run
|
|
25
|
+
expect(calls).toBe(1);
|
|
26
|
+
|
|
27
|
+
write(a, 2);
|
|
28
|
+
await Promise.resolve();
|
|
29
|
+
|
|
30
|
+
// a re-runs the effect; the untracked read of b picks up its current value
|
|
31
|
+
expect(calls).toBe(2);
|
|
32
|
+
expect(seenB).toEqual([10, 20]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('restores the observer when fn throws, so a later read in the same effect still tracks', async () => {
|
|
36
|
+
let a = signal(1),
|
|
37
|
+
b = signal(1),
|
|
38
|
+
calls = 0;
|
|
39
|
+
|
|
40
|
+
effect(() => {
|
|
41
|
+
calls++;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
untrack(() => {
|
|
45
|
+
throw new Error('untrack boom');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// swallow — only the observer-restoration-after-throw behavior is under test
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
read(a);
|
|
53
|
+
read(b);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(calls).toBe(1);
|
|
57
|
+
|
|
58
|
+
write(a, 2);
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
|
|
61
|
+
expect(calls).toBe(2);
|
|
62
|
+
|
|
63
|
+
write(b, 2);
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
|
|
66
|
+
expect(calls).toBe(3);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns the value fn produces', () => {
|
|
70
|
+
expect(untrack(() => 42)).toBe(42);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
describe('peek', () => {
|
|
76
|
+
it('returns the current value of a signal', () => {
|
|
77
|
+
let s = signal(5);
|
|
78
|
+
|
|
79
|
+
expect(peek(s)).toBe(5);
|
|
80
|
+
|
|
81
|
+
write(s, 6);
|
|
82
|
+
|
|
83
|
+
expect(peek(s)).toBe(6);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns the up-to-date value of a dirty computed without subscribing', async () => {
|
|
87
|
+
let s = signal(1),
|
|
88
|
+
c = computed(() => read(s) * 2),
|
|
89
|
+
writerRuns = 0;
|
|
90
|
+
|
|
91
|
+
effect(() => {
|
|
92
|
+
read(c);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
effect(() => {
|
|
96
|
+
read(s);
|
|
97
|
+
writerRuns++;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(writerRuns).toBe(1);
|
|
101
|
+
|
|
102
|
+
write(s, 5);
|
|
103
|
+
|
|
104
|
+
// No await — c is queued/dirty, stabilize() has not run yet
|
|
105
|
+
expect(peek(c)).toBe(10);
|
|
106
|
+
|
|
107
|
+
// peek must not create a subscription: the writer effect's run count is untouched
|
|
108
|
+
expect(writerRuns).toBe(1);
|
|
109
|
+
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not add a dependency link when called from inside a tracking scope', async () => {
|
|
115
|
+
let s = signal(1),
|
|
116
|
+
c = computed(() => read(s) * 2),
|
|
117
|
+
caller = computed(() => peek(c));
|
|
118
|
+
|
|
119
|
+
expect(read(caller)).toBe(2);
|
|
120
|
+
expect(caller.deps).toBe(null);
|
|
121
|
+
|
|
122
|
+
write(s, 5);
|
|
123
|
+
await Promise.resolve();
|
|
124
|
+
await Promise.resolve();
|
|
125
|
+
|
|
126
|
+
// caller never subscribed to c, so it does not react to s changing
|
|
127
|
+
expect(read(caller)).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rethrows the cached error for an errored computed', () => {
|
|
131
|
+
let s = signal(0),
|
|
132
|
+
c = computed(() => {
|
|
133
|
+
if (read(s) > 0) {
|
|
134
|
+
throw new Error('peek boom');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return read(s);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(peek(c)).toBe(0);
|
|
141
|
+
|
|
142
|
+
write(s, 1);
|
|
143
|
+
|
|
144
|
+
expect(() => peek(c)).toThrow('peek boom');
|
|
145
|
+
});
|
|
146
|
+
});
|