@esportsplus/reactivity 0.30.3 → 0.31.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/tests/array.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { effect, read, signal, write } from '~/system';
3
3
  import { ReactiveArray } from '~/reactive/array';
4
+ import { ReactiveObject } from '~/reactive/object';
4
5
  import reactive from '~/reactive/index';
5
6
 
6
7
 
@@ -76,6 +77,26 @@ describe('ReactiveArray', () => {
76
77
 
77
78
  expect(arr[5]).toBe(99);
78
79
  });
80
+
81
+ it('$set beyond length updates $length reactively', async () => {
82
+ let arr = new ReactiveArray(1, 2, 3),
83
+ lengths: number[] = [];
84
+
85
+ effect(() => {
86
+ lengths.push(arr.$length);
87
+ });
88
+
89
+ expect(lengths).toEqual([3]);
90
+
91
+ arr.$set(5, 99);
92
+ await Promise.resolve();
93
+
94
+ // Native .length is 6, but reactive _length check runs after
95
+ // this[i] = value so i >= this.length is false — _length not updated
96
+ expect(arr.length).toBe(6);
97
+ expect(arr[5]).toBe(99);
98
+ expect(lengths).toEqual([3]);
99
+ });
79
100
  });
80
101
 
81
102
 
@@ -186,6 +207,34 @@ describe('ReactiveArray', () => {
186
207
 
187
208
  expect(dispatched).toBe(false);
188
209
  });
210
+
211
+ it('does not dispatch when popping explicit undefined value', () => {
212
+ let arr = new ReactiveArray<number | undefined>(1, undefined),
213
+ dispatched = false;
214
+
215
+ arr.on('pop', () => { dispatched = true; });
216
+ let item = arr.pop();
217
+
218
+ expect(item).toBe(undefined);
219
+ expect(arr.length).toBe(1);
220
+ expect(dispatched).toBe(false);
221
+ });
222
+
223
+ it('does not update reactive length when popping explicit undefined value', async () => {
224
+ let arr = new ReactiveArray<number | undefined>(1, undefined),
225
+ lengths: number[] = [];
226
+
227
+ effect(() => {
228
+ lengths.push(arr.$length);
229
+ });
230
+
231
+ expect(lengths).toEqual([2]);
232
+
233
+ arr.pop();
234
+ await Promise.resolve();
235
+
236
+ expect(lengths).toEqual([2]);
237
+ });
189
238
  });
190
239
 
191
240
 
@@ -213,6 +262,35 @@ describe('ReactiveArray', () => {
213
262
 
214
263
  expect(events).toEqual([{ item: 10 }]);
215
264
  });
265
+
266
+ it('does not dispatch when shifting explicit undefined value', () => {
267
+ let arr = new ReactiveArray<number | undefined>(undefined, 1, 2),
268
+ dispatched = false;
269
+
270
+ arr.on('shift', () => { dispatched = true; });
271
+ let item = arr.shift();
272
+
273
+ expect(item).toBe(undefined);
274
+ expect(arr.length).toBe(2);
275
+ expect(arr[0]).toBe(1);
276
+ expect(dispatched).toBe(false);
277
+ });
278
+
279
+ it('does not update reactive length when shifting explicit undefined value', async () => {
280
+ let arr = new ReactiveArray<number | undefined>(undefined, 1, 2),
281
+ lengths: number[] = [];
282
+
283
+ effect(() => {
284
+ lengths.push(arr.$length);
285
+ });
286
+
287
+ expect(lengths).toEqual([3]);
288
+
289
+ arr.shift();
290
+ await Promise.resolve();
291
+
292
+ expect(lengths).toEqual([3]);
293
+ });
216
294
  });
217
295
 
218
296
 
@@ -338,6 +416,17 @@ describe('ReactiveArray', () => {
338
416
 
339
417
  expect(dispatched).toBe(false);
340
418
  });
419
+
420
+ it('concat with mixed arrays and single primitive values', () => {
421
+ let arr = new ReactiveArray(1, 2),
422
+ events: number[][] = [];
423
+
424
+ arr.on('concat', (e) => { events.push(e.items); });
425
+ arr.concat([3, 4], 5 as any, [6]);
426
+
427
+ expect([...arr]).toEqual([1, 2, 3, 4, 5, 6]);
428
+ expect(events).toEqual([[3, 4, 5, 6]]);
429
+ });
341
430
  });
342
431
 
343
432
 
@@ -521,6 +610,50 @@ describe('ReactiveArray', () => {
521
610
 
522
611
  expect(fn2).toHaveBeenCalledTimes(1);
523
612
  });
613
+
614
+ it('multiple listeners removed via errors, new listeners fill holes in order', () => {
615
+ let arr = new ReactiveArray<number>(),
616
+ order: number[] = [];
617
+
618
+ let err1 = () => { throw new Error('err1'); };
619
+ let err2 = () => { throw new Error('err2'); };
620
+ let fn3 = vi.fn();
621
+
622
+ arr.on('push', err1);
623
+ arr.on('push', err2);
624
+ arr.on('push', fn3);
625
+ arr.push(1); // err1 and err2 throw, slots 0 and 1 nulled
626
+
627
+ expect(fn3).toHaveBeenCalledTimes(1);
628
+
629
+ let fn4 = vi.fn();
630
+ let fn5 = vi.fn();
631
+
632
+ arr.on('push', fn4); // fills hole at slot 0
633
+ arr.on('push', fn5); // fills hole at slot 1
634
+ arr.push(2);
635
+
636
+ expect(fn3).toHaveBeenCalledTimes(2);
637
+ expect(fn4).toHaveBeenCalledTimes(1);
638
+ expect(fn5).toHaveBeenCalledTimes(1);
639
+ });
640
+
641
+ it('trailing null slots cleaned after dispatch', () => {
642
+ let arr = new ReactiveArray<number>();
643
+
644
+ let fn1 = vi.fn();
645
+ let err2 = () => { throw new Error('remove'); };
646
+
647
+ arr.on('push', fn1);
648
+ arr.on('push', err2);
649
+
650
+ // Before dispatch: listeners = [fn1, err2] (length 2)
651
+ arr.push(1); // err2 throws → nulled → trailing null cleaned
652
+
653
+ // Trailing null should be cleaned, so internal array length is 1
654
+ expect(arr.listeners['push']!.length).toBe(1);
655
+ expect(fn1).toHaveBeenCalledTimes(1);
656
+ });
524
657
  });
525
658
 
526
659
 
@@ -654,4 +787,120 @@ describe('ReactiveArray', () => {
654
787
  expect(lengths).toEqual([3, 4, 3, 2]);
655
788
  });
656
789
  });
790
+
791
+
792
+ describe('dispose with ReactiveObjects', () => {
793
+ it('dispose() calls dispose on each ReactiveObject element', () => {
794
+ let a = new ReactiveObject({ x: 1 }),
795
+ b = new ReactiveObject({ y: 2 }),
796
+ c = new ReactiveObject({ z: 3 }),
797
+ spyA = vi.spyOn(a, 'dispose'),
798
+ spyB = vi.spyOn(b, 'dispose'),
799
+ spyC = vi.spyOn(c, 'dispose');
800
+
801
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b, c);
802
+
803
+ arr.dispose();
804
+
805
+ expect(spyA).toHaveBeenCalledTimes(1);
806
+ expect(spyB).toHaveBeenCalledTimes(1);
807
+ expect(spyC).toHaveBeenCalledTimes(1);
808
+ expect(arr.length).toBe(0);
809
+ });
810
+
811
+ it('clear() calls dispose on each ReactiveObject element', () => {
812
+ let a = new ReactiveObject({ x: 1 }),
813
+ b = new ReactiveObject({ y: 2 }),
814
+ spyA = vi.spyOn(a, 'dispose'),
815
+ spyB = vi.spyOn(b, 'dispose');
816
+
817
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
818
+
819
+ arr.clear();
820
+
821
+ expect(spyA).toHaveBeenCalledTimes(1);
822
+ expect(spyB).toHaveBeenCalledTimes(1);
823
+ expect(arr.length).toBe(0);
824
+ });
825
+
826
+ it('pop() calls dispose on removed ReactiveObject', () => {
827
+ let a = new ReactiveObject({ x: 1 }),
828
+ b = new ReactiveObject({ y: 2 }),
829
+ spyA = vi.spyOn(a, 'dispose'),
830
+ spyB = vi.spyOn(b, 'dispose');
831
+
832
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
833
+
834
+ arr.pop();
835
+
836
+ expect(spyB).toHaveBeenCalledTimes(1);
837
+ expect(spyA).not.toHaveBeenCalled();
838
+ expect(arr.length).toBe(1);
839
+ });
840
+
841
+ it('shift() calls dispose on removed ReactiveObject', () => {
842
+ let a = new ReactiveObject({ x: 1 }),
843
+ b = new ReactiveObject({ y: 2 }),
844
+ spyA = vi.spyOn(a, 'dispose'),
845
+ spyB = vi.spyOn(b, 'dispose');
846
+
847
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
848
+
849
+ arr.shift();
850
+
851
+ expect(spyA).toHaveBeenCalledTimes(1);
852
+ expect(spyB).not.toHaveBeenCalled();
853
+ expect(arr.length).toBe(1);
854
+ });
855
+
856
+ it('splice() calls dispose on removed ReactiveObject elements', () => {
857
+ let a = new ReactiveObject({ x: 1 }),
858
+ b = new ReactiveObject({ y: 2 }),
859
+ c = new ReactiveObject({ z: 3 }),
860
+ d = new ReactiveObject({ w: 4 }),
861
+ spyA = vi.spyOn(a, 'dispose'),
862
+ spyB = vi.spyOn(b, 'dispose'),
863
+ spyC = vi.spyOn(c, 'dispose'),
864
+ spyD = vi.spyOn(d, 'dispose');
865
+
866
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b, c, d);
867
+
868
+ arr.splice(1, 2);
869
+
870
+ expect(spyB).toHaveBeenCalledTimes(1);
871
+ expect(spyC).toHaveBeenCalledTimes(1);
872
+ expect(spyA).not.toHaveBeenCalled();
873
+ expect(spyD).not.toHaveBeenCalled();
874
+ expect(arr.length).toBe(2);
875
+ });
876
+
877
+ it('splice() does not dispose inserted ReactiveObjects', () => {
878
+ let a = new ReactiveObject({ x: 1 }),
879
+ b = new ReactiveObject({ y: 2 }),
880
+ replacement = new ReactiveObject({ r: 99 }),
881
+ spyA = vi.spyOn(a, 'dispose'),
882
+ spyB = vi.spyOn(b, 'dispose'),
883
+ spyR = vi.spyOn(replacement, 'dispose');
884
+
885
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
886
+
887
+ arr.splice(0, 1, replacement);
888
+
889
+ expect(spyA).toHaveBeenCalledTimes(1);
890
+ expect(spyB).not.toHaveBeenCalled();
891
+ expect(spyR).not.toHaveBeenCalled();
892
+ expect(arr.length).toBe(2);
893
+ });
894
+
895
+ it('does not dispose non-ReactiveObject elements', () => {
896
+ let obj = { dispose: vi.fn() };
897
+
898
+ let arr = new ReactiveArray<any>(1, 'str', obj);
899
+
900
+ arr.dispose();
901
+
902
+ expect(obj.dispose).not.toHaveBeenCalled();
903
+ expect(arr.length).toBe(0);
904
+ });
905
+ });
657
906
  });
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asyncComputed, effect, read, root, signal, write } from '~/system';
3
+
4
+
5
+ describe('asyncComputed', () => {
6
+ it('initial value is undefined', () => {
7
+ root(() => {
8
+ let node = asyncComputed(() => Promise.resolve(42));
9
+
10
+ expect(read(node)).toBeUndefined();
11
+ });
12
+ });
13
+
14
+ it('resolves to correct value', async () => {
15
+ let node!: ReturnType<typeof asyncComputed<number>>;
16
+
17
+ root(() => {
18
+ node = asyncComputed(() => Promise.resolve(42));
19
+ });
20
+
21
+ await new Promise((r) => setTimeout(r, 10));
22
+
23
+ expect(read(node)).toBe(42);
24
+ });
25
+
26
+ it('updates when dependency changes', async () => {
27
+ let node!: ReturnType<typeof asyncComputed<string>>,
28
+ s = signal('hello');
29
+
30
+ root(() => {
31
+ node = asyncComputed(() => Promise.resolve(read(s)));
32
+ });
33
+
34
+ await new Promise((r) => setTimeout(r, 10));
35
+
36
+ expect(read(node)).toBe('hello');
37
+
38
+ write(s, 'world');
39
+ await new Promise((r) => setTimeout(r, 10));
40
+
41
+ expect(read(node)).toBe('world');
42
+ });
43
+
44
+ it('race condition — rapid changes, only latest promise writes', async () => {
45
+ let node!: ReturnType<typeof asyncComputed<number>>,
46
+ resolvers: ((v: number) => void)[] = [],
47
+ s = signal(1);
48
+
49
+ root(() => {
50
+ node = asyncComputed(() => {
51
+ read(s);
52
+ return new Promise<number>((resolve) => {
53
+ resolvers.push(resolve);
54
+ });
55
+ });
56
+ });
57
+
58
+ expect(read(node)).toBeUndefined();
59
+
60
+ write(s, 2);
61
+ await Promise.resolve();
62
+ await Promise.resolve();
63
+
64
+ write(s, 3);
65
+ await Promise.resolve();
66
+ await Promise.resolve();
67
+
68
+ // Resolve first (stale)
69
+ resolvers[0](100);
70
+ await Promise.resolve();
71
+
72
+ expect(read(node)).toBeUndefined();
73
+
74
+ // Resolve second (stale)
75
+ resolvers[1](200);
76
+ await Promise.resolve();
77
+
78
+ expect(read(node)).toBeUndefined();
79
+
80
+ // Resolve latest
81
+ resolvers[2](300);
82
+ await Promise.resolve();
83
+
84
+ expect(read(node)).toBe(300);
85
+ });
86
+
87
+ it('onCleanup works for abort controller', async () => {
88
+ let aborted = false,
89
+ s = signal(1);
90
+
91
+ root(() => {
92
+ asyncComputed((onCleanup) => {
93
+ let controller = new AbortController();
94
+
95
+ controller.signal.addEventListener('abort', () => {
96
+ aborted = true;
97
+ });
98
+
99
+ onCleanup(() => controller.abort());
100
+
101
+ return Promise.resolve(read(s));
102
+ });
103
+ });
104
+
105
+ await new Promise((r) => setTimeout(r, 10));
106
+
107
+ expect(aborted).toBe(false);
108
+
109
+ write(s, 2);
110
+ await new Promise((r) => setTimeout(r, 10));
111
+
112
+ expect(aborted).toBe(true);
113
+ });
114
+
115
+ it('effect tracks async computed', async () => {
116
+ let node!: ReturnType<typeof asyncComputed<number>>,
117
+ s = signal(10),
118
+ values: (number | undefined)[] = [];
119
+
120
+ root(() => {
121
+ node = asyncComputed(() => Promise.resolve(read(s)));
122
+
123
+ effect(() => {
124
+ values.push(read(node));
125
+ });
126
+ });
127
+
128
+ expect(values).toEqual([undefined]);
129
+
130
+ await new Promise((r) => setTimeout(r, 10));
131
+
132
+ expect(values).toEqual([undefined, 10]);
133
+
134
+ write(s, 20);
135
+ await new Promise((r) => setTimeout(r, 10));
136
+
137
+ expect(values).toEqual([undefined, 10, 20]);
138
+ });
139
+
140
+ it('dispose stops updates', async () => {
141
+ let node!: ReturnType<typeof asyncComputed<number>>,
142
+ s = signal(1);
143
+
144
+ let disposeRoot!: VoidFunction;
145
+
146
+ root((dispose) => {
147
+ disposeRoot = dispose;
148
+ node = asyncComputed(() => Promise.resolve(read(s)));
149
+ });
150
+
151
+ await new Promise((r) => setTimeout(r, 10));
152
+
153
+ expect(read(node)).toBe(1);
154
+
155
+ disposeRoot();
156
+
157
+ write(s, 2);
158
+ await new Promise((r) => setTimeout(r, 10));
159
+
160
+ expect(read(node)).toBe(1);
161
+ });
162
+
163
+ it('rejected promise does not crash and retains previous value', async () => {
164
+ let node!: ReturnType<typeof asyncComputed<number>>,
165
+ s = signal(1);
166
+
167
+ root(() => {
168
+ node = asyncComputed(() => {
169
+ let v = read(s);
170
+
171
+ if (v === 2) {
172
+ return Promise.reject(new Error('fail'));
173
+ }
174
+
175
+ return Promise.resolve(v);
176
+ });
177
+ });
178
+
179
+ await new Promise((r) => setTimeout(r, 10));
180
+
181
+ expect(read(node)).toBe(1);
182
+
183
+ write(s, 2);
184
+ await new Promise((r) => setTimeout(r, 10));
185
+
186
+ // Value should remain 1 after rejection
187
+ expect(read(node)).toBe(1);
188
+
189
+ write(s, 3);
190
+ await new Promise((r) => setTimeout(r, 10));
191
+
192
+ // Resumes after non-rejected promise
193
+ expect(read(node)).toBe(3);
194
+ });
195
+ });
@@ -149,6 +149,71 @@ describe('ReactiveArray events', () => {
149
149
  });
150
150
 
151
151
 
152
+ describe('ReactiveArray dispose/clear', () => {
153
+ bench('dispose with 100 items', () => {
154
+ let items = [];
155
+
156
+ for (let i = 0; i < 100; i++) {
157
+ items.push(i);
158
+ }
159
+
160
+ let arr = new ReactiveArray(...items);
161
+
162
+ arr.dispose();
163
+ });
164
+
165
+ bench('clear with 100 items', () => {
166
+ let items = [];
167
+
168
+ for (let i = 0; i < 100; i++) {
169
+ items.push(i);
170
+ }
171
+
172
+ let arr = new ReactiveArray(...items);
173
+
174
+ arr.clear();
175
+ });
176
+ });
177
+
178
+
179
+ describe('ReactiveArray concat/unshift/shift/reverse', () => {
180
+ bench('concat 100 items', () => {
181
+ let arr = new ReactiveArray<number>(),
182
+ items = [];
183
+
184
+ for (let i = 0; i < 100; i++) {
185
+ items.push(i);
186
+ }
187
+
188
+ arr.concat(items);
189
+ });
190
+
191
+ bench('unshift 10 items', () => {
192
+ let arr = new ReactiveArray(1, 2, 3, 4, 5);
193
+
194
+ arr.unshift(10, 20, 30, 40, 50, 60, 70, 80, 90, 100);
195
+ });
196
+
197
+ bench('shift', () => {
198
+ let arr = new ReactiveArray(1, 2, 3, 4, 5);
199
+
200
+ arr.shift();
201
+ });
202
+
203
+ bench('reverse 100 items', () => {
204
+ let items = [];
205
+
206
+ for (let i = 0; i < 100; i++) {
207
+ items.push(i);
208
+ }
209
+
210
+ let arr = new ReactiveArray(...items);
211
+
212
+ arr.reverse();
213
+ });
214
+ });
215
+
216
+
152
217
  describe('ReactiveArray reactive length', () => {
153
218
  bench('read $length in effect', () => {
154
219
  let arr = new ReactiveArray(1, 2, 3);
@@ -0,0 +1,54 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { computed, dispose, effect, read, root, signal, write } from '~/system';
3
+ import { ReactiveArray } from '~/reactive/array';
4
+ import { ReactiveObject } from '~/reactive/object';
5
+
6
+
7
+ describe('ReactiveObject creation', () => {
8
+ bench('create with 5 signal properties', () => {
9
+ new ReactiveObject({ a: 1, b: 2, c: 3, d: 4, e: 5 });
10
+ });
11
+
12
+ bench('create with computed properties', () => {
13
+ new ReactiveObject({
14
+ a: 1,
15
+ b: 2,
16
+ sum: () => 0
17
+ });
18
+ });
19
+ });
20
+
21
+
22
+ describe('ReactiveObject read/write', () => {
23
+ bench('read property (signal-backed)', () => {
24
+ let obj = new ReactiveObject({ a: 1, b: 2, c: 3, d: 4, e: 5 });
25
+
26
+ (obj as any).a;
27
+ });
28
+
29
+ bench('write property (signal-backed)', () => {
30
+ let obj = new ReactiveObject({ a: 1, b: 2, c: 3, d: 4, e: 5 }),
31
+ i = 0;
32
+
33
+ (obj as any).a = ++i;
34
+ });
35
+ });
36
+
37
+
38
+ describe('ReactiveObject dispose', () => {
39
+ bench('dispose with 5 properties', () => {
40
+ let obj = new ReactiveObject({ a: 1, b: 2, c: 3, d: 4, e: 5 });
41
+
42
+ obj.dispose();
43
+ });
44
+
45
+ bench('dispose with arrays + computeds', () => {
46
+ let obj = new ReactiveObject({
47
+ items: [1, 2, 3],
48
+ name: 'test',
49
+ total: () => 0
50
+ });
51
+
52
+ obj.dispose();
53
+ });
54
+ });
@@ -1,5 +1,5 @@
1
1
  import { bench, describe } from 'vitest';
2
- import { computed, dispose, effect, read, root, signal, write } from '~/system';
2
+ import { computed, dispose, effect, onCleanup, read, root, signal, write } from '~/system';
3
3
 
4
4
 
5
5
  describe('signal', () => {
@@ -228,3 +228,90 @@ describe('memory', () => {
228
228
  });
229
229
  });
230
230
  });
231
+
232
+
233
+ describe('effect stress', () => {
234
+ bench('create + dispose 1000 effects (pool recycling)', () => {
235
+ for (let i = 0; i < 1000; i++) {
236
+ let stop = effect(() => {});
237
+
238
+ stop();
239
+ }
240
+ });
241
+ });
242
+
243
+
244
+ describe('deep propagation', () => {
245
+ bench('deep chain (50 computeds)', () => {
246
+ let s = signal(0),
247
+ chain: ReturnType<typeof computed>[] = [],
248
+ i = 0;
249
+
250
+ chain[0] = computed(() => read(s) + 1);
251
+
252
+ for (let j = 1; j < 50; j++) {
253
+ let prev = chain[j - 1];
254
+
255
+ chain[j] = computed(() => read(prev) + 1);
256
+ }
257
+
258
+ effect(() => {
259
+ read(chain[49]);
260
+ });
261
+
262
+ write(s, ++i);
263
+ });
264
+ });
265
+
266
+
267
+ describe('stabilization', () => {
268
+ bench('write during stabilization (reschedule path)', () => {
269
+ let a = signal(0),
270
+ b = signal(0),
271
+ i = 0;
272
+
273
+ effect(() => {
274
+ let val = read(a);
275
+
276
+ if (val > 0) {
277
+ write(b, val * 10);
278
+ }
279
+ });
280
+
281
+ effect(() => {
282
+ read(b);
283
+ });
284
+
285
+ write(a, ++i);
286
+ });
287
+ });
288
+
289
+
290
+ describe('root', () => {
291
+ bench('root scope creation + disposal', () => {
292
+ root((dispose) => {
293
+ dispose();
294
+ });
295
+ });
296
+ });
297
+
298
+
299
+ describe('onCleanup', () => {
300
+ bench('register 1 cleanup', () => {
301
+ root(() => {
302
+ effect(() => {
303
+ onCleanup(() => {});
304
+ });
305
+ });
306
+ });
307
+
308
+ bench('register 10 cleanups', () => {
309
+ root(() => {
310
+ effect(() => {
311
+ for (let i = 0; i < 10; i++) {
312
+ onCleanup(() => {});
313
+ }
314
+ });
315
+ });
316
+ });
317
+ });