@esportsplus/reactivity 0.30.3 → 0.31.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/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,55 @@ 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
+ });
100
+
101
+ it('$set creates sparse array without updating reactive $length', async () => {
102
+ let arr = new ReactiveArray<number>(),
103
+ lengths: number[] = [];
104
+
105
+ effect(() => {
106
+ lengths.push(arr.$length);
107
+ });
108
+
109
+ expect(lengths).toEqual([0]);
110
+
111
+ arr.$set(100, 42);
112
+ await Promise.resolve();
113
+
114
+ // Value is set at index 100
115
+ expect(arr[100]).toBe(42);
116
+
117
+ // Native length becomes 101 via Array behavior
118
+ expect(arr.length).toBe(101);
119
+
120
+ // Reactive $length NOT updated: this[100] = value sets native .length
121
+ // to 101 before the check, so 100 >= 101 is false
122
+ expect(lengths).toEqual([0]);
123
+
124
+ // Intermediate indices are empty (sparse)
125
+ expect(arr[0]).toBe(undefined);
126
+ expect(arr[50]).toBe(undefined);
127
+ expect(arr[99]).toBe(undefined);
128
+ });
79
129
  });
80
130
 
81
131
 
@@ -186,6 +236,34 @@ describe('ReactiveArray', () => {
186
236
 
187
237
  expect(dispatched).toBe(false);
188
238
  });
239
+
240
+ it('does not dispatch when popping explicit undefined value', () => {
241
+ let arr = new ReactiveArray<number | undefined>(1, undefined),
242
+ dispatched = false;
243
+
244
+ arr.on('pop', () => { dispatched = true; });
245
+ let item = arr.pop();
246
+
247
+ expect(item).toBe(undefined);
248
+ expect(arr.length).toBe(1);
249
+ expect(dispatched).toBe(false);
250
+ });
251
+
252
+ it('does not update reactive length when popping explicit undefined value', async () => {
253
+ let arr = new ReactiveArray<number | undefined>(1, undefined),
254
+ lengths: number[] = [];
255
+
256
+ effect(() => {
257
+ lengths.push(arr.$length);
258
+ });
259
+
260
+ expect(lengths).toEqual([2]);
261
+
262
+ arr.pop();
263
+ await Promise.resolve();
264
+
265
+ expect(lengths).toEqual([2]);
266
+ });
189
267
  });
190
268
 
191
269
 
@@ -213,6 +291,35 @@ describe('ReactiveArray', () => {
213
291
 
214
292
  expect(events).toEqual([{ item: 10 }]);
215
293
  });
294
+
295
+ it('does not dispatch when shifting explicit undefined value', () => {
296
+ let arr = new ReactiveArray<number | undefined>(undefined, 1, 2),
297
+ dispatched = false;
298
+
299
+ arr.on('shift', () => { dispatched = true; });
300
+ let item = arr.shift();
301
+
302
+ expect(item).toBe(undefined);
303
+ expect(arr.length).toBe(2);
304
+ expect(arr[0]).toBe(1);
305
+ expect(dispatched).toBe(false);
306
+ });
307
+
308
+ it('does not update reactive length when shifting explicit undefined value', async () => {
309
+ let arr = new ReactiveArray<number | undefined>(undefined, 1, 2),
310
+ lengths: number[] = [];
311
+
312
+ effect(() => {
313
+ lengths.push(arr.$length);
314
+ });
315
+
316
+ expect(lengths).toEqual([3]);
317
+
318
+ arr.shift();
319
+ await Promise.resolve();
320
+
321
+ expect(lengths).toEqual([3]);
322
+ });
216
323
  });
217
324
 
218
325
 
@@ -243,6 +350,26 @@ describe('ReactiveArray', () => {
243
350
 
244
351
  expect(events).toEqual([[1, 2]]);
245
352
  });
353
+
354
+ it('no-op for empty unshift', async () => {
355
+ let arr = new ReactiveArray(1, 2),
356
+ dispatched = false,
357
+ lengths: number[] = [];
358
+
359
+ arr.on('unshift', () => { dispatched = true; });
360
+
361
+ effect(() => { lengths.push(arr.$length); });
362
+
363
+ expect(lengths).toEqual([2]);
364
+
365
+ let result = arr.unshift();
366
+
367
+ await Promise.resolve();
368
+
369
+ expect(result).toBe(2);
370
+ expect(dispatched).toBe(false);
371
+ expect(lengths).toEqual([2]);
372
+ });
246
373
  });
247
374
 
248
375
 
@@ -291,6 +418,32 @@ describe('ReactiveArray', () => {
291
418
 
292
419
  expect(dispatched).toBe(false);
293
420
  });
421
+
422
+ it('splice with start beyond array length removes nothing', () => {
423
+ let arr = new ReactiveArray(1, 2, 3),
424
+ dispatched = false;
425
+
426
+ arr.on('splice', () => { dispatched = true; });
427
+
428
+ let removed = arr.splice(100, 1);
429
+
430
+ expect([...removed]).toEqual([]);
431
+ expect([...arr]).toEqual([1, 2, 3]);
432
+ expect(dispatched).toBe(false);
433
+ });
434
+
435
+ it('splice with negative start removes from end', () => {
436
+ let arr = new ReactiveArray(1, 2, 3, 4, 5),
437
+ events: { start: number; deleteCount: number; items: number[] }[] = [];
438
+
439
+ arr.on('splice', (e) => { events.push(e); });
440
+
441
+ let removed = arr.splice(-2, 1);
442
+
443
+ expect([...removed]).toEqual([4]);
444
+ expect([...arr]).toEqual([1, 2, 3, 5]);
445
+ expect(events).toEqual([{ start: -2, deleteCount: 1, items: [] }]);
446
+ });
294
447
  });
295
448
 
296
449
 
@@ -338,6 +491,17 @@ describe('ReactiveArray', () => {
338
491
 
339
492
  expect(dispatched).toBe(false);
340
493
  });
494
+
495
+ it('concat with mixed arrays and single primitive values', () => {
496
+ let arr = new ReactiveArray(1, 2),
497
+ events: number[][] = [];
498
+
499
+ arr.on('concat', (e) => { events.push(e.items); });
500
+ arr.concat([3, 4], 5 as any, [6]);
501
+
502
+ expect([...arr]).toEqual([1, 2, 3, 4, 5, 6]);
503
+ expect(events).toEqual([[3, 4, 5, 6]]);
504
+ });
341
505
  });
342
506
 
343
507
 
@@ -404,6 +568,20 @@ describe('ReactiveArray', () => {
404
568
 
405
569
  expect([...arr]).toEqual([1, 2, 2]);
406
570
  });
571
+
572
+ it('sort preserves object references', () => {
573
+ let a = { id: 3 },
574
+ b = { id: 1 },
575
+ c = { id: 2 },
576
+ arr = new ReactiveArray(a, b, c);
577
+
578
+ arr.sort((x, y) => x.id - y.id);
579
+
580
+ // Sorted order: b(1), c(2), a(3) — same object references
581
+ expect(arr[0]).toBe(b);
582
+ expect(arr[1]).toBe(c);
583
+ expect(arr[2]).toBe(a);
584
+ });
407
585
  });
408
586
 
409
587
 
@@ -521,6 +699,81 @@ describe('ReactiveArray', () => {
521
699
 
522
700
  expect(fn2).toHaveBeenCalledTimes(1);
523
701
  });
702
+
703
+ it('multiple listeners removed via errors, new listeners fill holes in order', () => {
704
+ let arr = new ReactiveArray<number>(),
705
+ order: number[] = [];
706
+
707
+ let err1 = () => { throw new Error('err1'); };
708
+ let err2 = () => { throw new Error('err2'); };
709
+ let fn3 = vi.fn();
710
+
711
+ arr.on('push', err1);
712
+ arr.on('push', err2);
713
+ arr.on('push', fn3);
714
+ arr.push(1); // err1 and err2 throw, slots 0 and 1 nulled
715
+
716
+ expect(fn3).toHaveBeenCalledTimes(1);
717
+
718
+ let fn4 = vi.fn();
719
+ let fn5 = vi.fn();
720
+
721
+ arr.on('push', fn4); // fills hole at slot 0
722
+ arr.on('push', fn5); // fills hole at slot 1
723
+ arr.push(2);
724
+
725
+ expect(fn3).toHaveBeenCalledTimes(2);
726
+ expect(fn4).toHaveBeenCalledTimes(1);
727
+ expect(fn5).toHaveBeenCalledTimes(1);
728
+ });
729
+
730
+ it('trailing null slots cleaned after dispatch', () => {
731
+ let arr = new ReactiveArray<number>();
732
+
733
+ let fn1 = vi.fn();
734
+ let err2 = () => { throw new Error('remove'); };
735
+
736
+ arr.on('push', fn1);
737
+ arr.on('push', err2);
738
+
739
+ // Before dispatch: listeners = [fn1, err2] (length 2)
740
+ arr.push(1); // err2 throws → nulled → trailing null cleaned
741
+
742
+ // Trailing null should be cleaned, so internal array length is 1
743
+ expect(arr.listeners['push']!.length).toBe(1);
744
+ expect(fn1).toHaveBeenCalledTimes(1);
745
+ });
746
+
747
+ it('on() inserts after trailing nulls cleaned from dispatch', () => {
748
+ let arr = new ReactiveArray<number>(),
749
+ fn1 = vi.fn(),
750
+ fn2 = () => { throw new Error('err2'); },
751
+ fn3 = () => { throw new Error('err3'); };
752
+
753
+ arr.on('push', fn1);
754
+ arr.on('push', fn2);
755
+ arr.on('push', fn3);
756
+
757
+ // Dispatch: fn2 and fn3 throw → nulled → trailing nulls cleaned
758
+ // listeners = [fn1, null, null] → cleanup → [fn1]
759
+ arr.push(1);
760
+
761
+ expect(fn1).toHaveBeenCalledTimes(1);
762
+ expect(arr.listeners['push']!.length).toBe(1);
763
+
764
+ // Register fn4 — should append at index 1 (no holes left)
765
+ let fn4 = vi.fn();
766
+
767
+ arr.on('push', fn4);
768
+
769
+ expect(arr.listeners['push']!.length).toBe(2);
770
+
771
+ // Both fn1 and fn4 called on next push
772
+ arr.push(2);
773
+
774
+ expect(fn1).toHaveBeenCalledTimes(2);
775
+ expect(fn4).toHaveBeenCalledTimes(1);
776
+ });
524
777
  });
525
778
 
526
779
 
@@ -654,4 +907,120 @@ describe('ReactiveArray', () => {
654
907
  expect(lengths).toEqual([3, 4, 3, 2]);
655
908
  });
656
909
  });
910
+
911
+
912
+ describe('dispose with ReactiveObjects', () => {
913
+ it('dispose() calls dispose on each ReactiveObject element', () => {
914
+ let a = new ReactiveObject({ x: 1 }),
915
+ b = new ReactiveObject({ y: 2 }),
916
+ c = new ReactiveObject({ z: 3 }),
917
+ spyA = vi.spyOn(a, 'dispose'),
918
+ spyB = vi.spyOn(b, 'dispose'),
919
+ spyC = vi.spyOn(c, 'dispose');
920
+
921
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b, c);
922
+
923
+ arr.dispose();
924
+
925
+ expect(spyA).toHaveBeenCalledTimes(1);
926
+ expect(spyB).toHaveBeenCalledTimes(1);
927
+ expect(spyC).toHaveBeenCalledTimes(1);
928
+ expect(arr.length).toBe(0);
929
+ });
930
+
931
+ it('clear() calls dispose on each ReactiveObject element', () => {
932
+ let a = new ReactiveObject({ x: 1 }),
933
+ b = new ReactiveObject({ y: 2 }),
934
+ spyA = vi.spyOn(a, 'dispose'),
935
+ spyB = vi.spyOn(b, 'dispose');
936
+
937
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
938
+
939
+ arr.clear();
940
+
941
+ expect(spyA).toHaveBeenCalledTimes(1);
942
+ expect(spyB).toHaveBeenCalledTimes(1);
943
+ expect(arr.length).toBe(0);
944
+ });
945
+
946
+ it('pop() calls dispose on removed ReactiveObject', () => {
947
+ let a = new ReactiveObject({ x: 1 }),
948
+ b = new ReactiveObject({ y: 2 }),
949
+ spyA = vi.spyOn(a, 'dispose'),
950
+ spyB = vi.spyOn(b, 'dispose');
951
+
952
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
953
+
954
+ arr.pop();
955
+
956
+ expect(spyB).toHaveBeenCalledTimes(1);
957
+ expect(spyA).not.toHaveBeenCalled();
958
+ expect(arr.length).toBe(1);
959
+ });
960
+
961
+ it('shift() calls dispose on removed ReactiveObject', () => {
962
+ let a = new ReactiveObject({ x: 1 }),
963
+ b = new ReactiveObject({ y: 2 }),
964
+ spyA = vi.spyOn(a, 'dispose'),
965
+ spyB = vi.spyOn(b, 'dispose');
966
+
967
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
968
+
969
+ arr.shift();
970
+
971
+ expect(spyA).toHaveBeenCalledTimes(1);
972
+ expect(spyB).not.toHaveBeenCalled();
973
+ expect(arr.length).toBe(1);
974
+ });
975
+
976
+ it('splice() calls dispose on removed ReactiveObject elements', () => {
977
+ let a = new ReactiveObject({ x: 1 }),
978
+ b = new ReactiveObject({ y: 2 }),
979
+ c = new ReactiveObject({ z: 3 }),
980
+ d = new ReactiveObject({ w: 4 }),
981
+ spyA = vi.spyOn(a, 'dispose'),
982
+ spyB = vi.spyOn(b, 'dispose'),
983
+ spyC = vi.spyOn(c, 'dispose'),
984
+ spyD = vi.spyOn(d, 'dispose');
985
+
986
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b, c, d);
987
+
988
+ arr.splice(1, 2);
989
+
990
+ expect(spyB).toHaveBeenCalledTimes(1);
991
+ expect(spyC).toHaveBeenCalledTimes(1);
992
+ expect(spyA).not.toHaveBeenCalled();
993
+ expect(spyD).not.toHaveBeenCalled();
994
+ expect(arr.length).toBe(2);
995
+ });
996
+
997
+ it('splice() does not dispose inserted ReactiveObjects', () => {
998
+ let a = new ReactiveObject({ x: 1 }),
999
+ b = new ReactiveObject({ y: 2 }),
1000
+ replacement = new ReactiveObject({ r: 99 }),
1001
+ spyA = vi.spyOn(a, 'dispose'),
1002
+ spyB = vi.spyOn(b, 'dispose'),
1003
+ spyR = vi.spyOn(replacement, 'dispose');
1004
+
1005
+ let arr = new ReactiveArray<ReactiveObject<any>>(a, b);
1006
+
1007
+ arr.splice(0, 1, replacement);
1008
+
1009
+ expect(spyA).toHaveBeenCalledTimes(1);
1010
+ expect(spyB).not.toHaveBeenCalled();
1011
+ expect(spyR).not.toHaveBeenCalled();
1012
+ expect(arr.length).toBe(2);
1013
+ });
1014
+
1015
+ it('does not dispose non-ReactiveObject elements', () => {
1016
+ let obj = { dispose: vi.fn() };
1017
+
1018
+ let arr = new ReactiveArray<any>(1, 'str', obj);
1019
+
1020
+ arr.dispose();
1021
+
1022
+ expect(obj.dispose).not.toHaveBeenCalled();
1023
+ expect(arr.length).toBe(0);
1024
+ });
1025
+ });
657
1026
  });
@@ -0,0 +1,239 @@
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('nested asyncComputed — B depends on A', async () => {
164
+ let s = signal(5),
165
+ nodeA!: ReturnType<typeof asyncComputed<number>>,
166
+ nodeB!: ReturnType<typeof asyncComputed<number>>;
167
+
168
+ root(() => {
169
+ nodeA = asyncComputed(() => Promise.resolve(read(s) * 2));
170
+
171
+ nodeB = asyncComputed(() => {
172
+ let a = read(nodeA);
173
+
174
+ if (a === undefined) {
175
+ return Promise.resolve(0);
176
+ }
177
+
178
+ return Promise.resolve(a + 100);
179
+ });
180
+ });
181
+
182
+ // Initially both undefined
183
+ expect(read(nodeA)).toBeUndefined();
184
+ expect(read(nodeB)).toBeUndefined();
185
+
186
+ // Wait for A to resolve
187
+ await new Promise((r) => setTimeout(r, 10));
188
+
189
+ expect(read(nodeA)).toBe(10);
190
+
191
+ // Wait for B to react to A's resolved value
192
+ await new Promise((r) => setTimeout(r, 10));
193
+
194
+ expect(read(nodeB)).toBe(110);
195
+
196
+ // Update source signal — A and B should both update
197
+ write(s, 20);
198
+ await new Promise((r) => setTimeout(r, 20));
199
+
200
+ expect(read(nodeA)).toBe(40);
201
+
202
+ await new Promise((r) => setTimeout(r, 10));
203
+
204
+ expect(read(nodeB)).toBe(140);
205
+ });
206
+
207
+ it('rejected promise does not crash and retains previous value', async () => {
208
+ let node!: ReturnType<typeof asyncComputed<number>>,
209
+ s = signal(1);
210
+
211
+ root(() => {
212
+ node = asyncComputed(() => {
213
+ let v = read(s);
214
+
215
+ if (v === 2) {
216
+ return Promise.reject(new Error('fail'));
217
+ }
218
+
219
+ return Promise.resolve(v);
220
+ });
221
+ });
222
+
223
+ await new Promise((r) => setTimeout(r, 10));
224
+
225
+ expect(read(node)).toBe(1);
226
+
227
+ write(s, 2);
228
+ await new Promise((r) => setTimeout(r, 10));
229
+
230
+ // Value should remain 1 after rejection
231
+ expect(read(node)).toBe(1);
232
+
233
+ write(s, 3);
234
+ await new Promise((r) => setTimeout(r, 10));
235
+
236
+ // Resumes after non-rejected promise
237
+ expect(read(node)).toBe(3);
238
+ });
239
+ });