@esportsplus/reactivity 0.30.2 → 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.
@@ -0,0 +1,534 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { computed, effect, onCleanup, read, root, signal, write } from '~/system';
3
+ import { SIGNAL } from '~/constants';
4
+ import { ReactiveObject, isReactiveObject } from '~/reactive/object';
5
+ import reactive from '~/reactive/index';
6
+ import { ReactiveArray } from '~/reactive/array';
7
+
8
+
9
+ describe('ReactiveObject', () => {
10
+ describe('constructor', () => {
11
+ it('creates reactive object from plain object', () => {
12
+ let obj = new ReactiveObject({ a: 1, b: 'hello' });
13
+
14
+ expect((obj as any).a).toBe(1);
15
+ expect((obj as any).b).toBe('hello');
16
+ });
17
+
18
+ it('null constructor does nothing', () => {
19
+ let obj = new ReactiveObject(null);
20
+
21
+ expect(obj).toBeInstanceOf(ReactiveObject);
22
+ });
23
+
24
+ it('makes properties reactive', async () => {
25
+ let obj = new ReactiveObject({ count: 0 }) as any,
26
+ values: number[] = [];
27
+
28
+ effect(() => {
29
+ values.push(obj.count);
30
+ });
31
+
32
+ expect(values).toEqual([0]);
33
+
34
+ obj.count = 5;
35
+ await Promise.resolve();
36
+
37
+ expect(values).toEqual([0, 5]);
38
+ });
39
+ });
40
+
41
+
42
+ describe('computed properties', () => {
43
+ it('creates computed from external signal dependency', () => {
44
+ let s = signal(10),
45
+ obj = new ReactiveObject({
46
+ doubled: () => read(s) * 2
47
+ }) as any;
48
+
49
+ expect(obj.doubled).toBe(20);
50
+ });
51
+
52
+ it('updates computed when external dependency changes', async () => {
53
+ let s = signal(1),
54
+ obj = new ReactiveObject({
55
+ doubled: () => read(s) * 2
56
+ }) as any;
57
+
58
+ expect(obj.doubled).toBe(2);
59
+
60
+ write(s, 5);
61
+ await Promise.resolve();
62
+
63
+ expect(obj.doubled).toBe(10);
64
+ });
65
+ });
66
+
67
+
68
+ describe('array properties', () => {
69
+ it('wraps arrays as ReactiveArray', () => {
70
+ let obj = new ReactiveObject({ items: [1, 2, 3] }) as any;
71
+
72
+ expect(obj.items).toBeInstanceOf(ReactiveArray);
73
+ expect(obj.items.length).toBe(3);
74
+ });
75
+
76
+ it('reactive arrays track length', async () => {
77
+ let obj = new ReactiveObject({ items: [1, 2] }) as any,
78
+ lengths: number[] = [];
79
+
80
+ effect(() => {
81
+ lengths.push(obj.items.$length);
82
+ });
83
+
84
+ obj.items.push(3);
85
+ await Promise.resolve();
86
+
87
+ expect(lengths).toEqual([2, 3]);
88
+ });
89
+ });
90
+
91
+
92
+ describe('dispose', () => {
93
+ it('disposes all nested resources', () => {
94
+ let obj = new ReactiveObject({
95
+ count: 1,
96
+ items: [1, 2, 3]
97
+ }) as any;
98
+
99
+ // Should not throw
100
+ obj.dispose();
101
+ });
102
+
103
+ it('computed stops updating after dispose', async () => {
104
+ let obj = new ReactiveObject({
105
+ count: 1,
106
+ doubled: () => (obj as any).count * 2
107
+ }) as any;
108
+
109
+ obj.dispose();
110
+ });
111
+ });
112
+
113
+
114
+ describe('async computed', () => {
115
+ it('initial value undefined, resolves to correct value', async () => {
116
+ let s = signal(42),
117
+ obj = new ReactiveObject({
118
+ data: () => Promise.resolve(read(s))
119
+ }) as any;
120
+
121
+ expect(obj.data).toBeUndefined();
122
+
123
+ await new Promise((r) => setTimeout(r, 10));
124
+
125
+ expect(obj.data).toBe(42);
126
+ });
127
+
128
+ it('updates when dependency changes — new promise resolves', async () => {
129
+ let s = signal('hello'),
130
+ obj = new ReactiveObject({
131
+ data: () => Promise.resolve(read(s))
132
+ }) as any;
133
+
134
+ await new Promise((r) => setTimeout(r, 10));
135
+
136
+ expect(obj.data).toBe('hello');
137
+
138
+ write(s, 'world');
139
+ await new Promise((r) => setTimeout(r, 10));
140
+
141
+ expect(obj.data).toBe('world');
142
+ });
143
+
144
+ it('race condition — rapid changes, only latest promise writes', async () => {
145
+ let s = signal(1),
146
+ resolvers: ((v: number) => void)[] = [],
147
+ obj = new ReactiveObject({
148
+ data: () => new Promise<number>((resolve) => {
149
+ resolvers.push(resolve);
150
+ read(s);
151
+ })
152
+ }) as any;
153
+
154
+ expect(obj.data).toBeUndefined();
155
+
156
+ // Trigger second computation
157
+ write(s, 2);
158
+ await Promise.resolve();
159
+ await Promise.resolve();
160
+
161
+ // Trigger third computation
162
+ write(s, 3);
163
+ await Promise.resolve();
164
+ await Promise.resolve();
165
+
166
+ // Resolve first promise (stale — should be ignored)
167
+ resolvers[0](100);
168
+ await Promise.resolve();
169
+
170
+ expect(obj.data).toBeUndefined();
171
+
172
+ // Resolve second promise (stale — should be ignored)
173
+ resolvers[1](200);
174
+ await Promise.resolve();
175
+
176
+ expect(obj.data).toBeUndefined();
177
+
178
+ // Resolve latest promise — should write
179
+ resolvers[2](300);
180
+ await Promise.resolve();
181
+
182
+ expect(obj.data).toBe(300);
183
+ });
184
+
185
+ it('dispose prevents new computations but in-flight resolves still write', async () => {
186
+ let s = signal(1),
187
+ resolver: ((v: number) => void) | null = null,
188
+ obj = new ReactiveObject({
189
+ data: () => new Promise<number>((resolve) => {
190
+ resolver = resolve;
191
+ read(s);
192
+ })
193
+ }) as any;
194
+
195
+ expect(obj.data).toBeUndefined();
196
+
197
+ obj.dispose();
198
+
199
+ // After dispose, dependency changes don't spawn new effects
200
+ write(s, 2);
201
+ await Promise.resolve();
202
+
203
+ // Resolve the original in-flight promise
204
+ resolver!(999);
205
+ await Promise.resolve();
206
+
207
+ // In-flight promise still wrote (version matched)
208
+ expect(obj.data).toBe(999);
209
+
210
+ // But no further computations occur from new dependency changes
211
+ write(s, 3);
212
+ await new Promise((r) => setTimeout(r, 10));
213
+
214
+ expect(obj.data).toBe(999);
215
+ });
216
+ });
217
+
218
+
219
+ describe('null/undefined properties', () => {
220
+ it('creates reactive signal for null property', async () => {
221
+ let obj = new ReactiveObject({ key: null }) as any,
222
+ values: (null | number)[] = [];
223
+
224
+ expect(obj.key).toBeNull();
225
+
226
+ effect(() => {
227
+ values.push(obj.key);
228
+ });
229
+
230
+ expect(values).toEqual([null]);
231
+
232
+ obj.key = 42;
233
+ await Promise.resolve();
234
+
235
+ expect(values).toEqual([null, 42]);
236
+ });
237
+
238
+ it('creates reactive signal for undefined property', async () => {
239
+ let obj = new ReactiveObject({ key: undefined }) as any,
240
+ values: (undefined | string)[] = [];
241
+
242
+ expect(obj.key).toBeUndefined();
243
+
244
+ effect(() => {
245
+ values.push(obj.key);
246
+ });
247
+
248
+ expect(values).toEqual([undefined]);
249
+
250
+ obj.key = 'hello';
251
+ await Promise.resolve();
252
+
253
+ expect(values).toEqual([undefined, 'hello']);
254
+ });
255
+ });
256
+
257
+
258
+ describe('enumeration', () => {
259
+ it('Object.keys includes all defined property names', () => {
260
+ let keys = Object.keys(new ReactiveObject({ a: 1, b: 'two', c: null }));
261
+
262
+ expect(keys).toContain('a');
263
+ expect(keys).toContain('b');
264
+ expect(keys).toContain('c');
265
+ });
266
+
267
+ it('for...in iterates all defined properties', () => {
268
+ let obj = new ReactiveObject({ x: 10, y: 20, z: 30 }),
269
+ keys: string[] = [];
270
+
271
+ for (let key in obj) {
272
+ keys.push(key);
273
+ }
274
+
275
+ expect(keys).toContain('x');
276
+ expect(keys).toContain('y');
277
+ expect(keys).toContain('z');
278
+ });
279
+ });
280
+
281
+
282
+ describe('[SIGNAL] protected method', () => {
283
+ it('subclass creates reactive field via [SIGNAL]', async () => {
284
+ class Counter extends ReactiveObject<Record<string, never>> {
285
+ private _count: ReturnType<typeof signal<number>>;
286
+
287
+ constructor(initial: number) {
288
+ super(null);
289
+ this._count = this[SIGNAL](initial);
290
+ }
291
+
292
+ get count() {
293
+ return read(this._count);
294
+ }
295
+
296
+ set count(value: number) {
297
+ write(this._count, value);
298
+ }
299
+ }
300
+
301
+ let counter = new Counter(0),
302
+ values: number[] = [];
303
+
304
+ effect(() => {
305
+ values.push(counter.count);
306
+ });
307
+
308
+ expect(values).toEqual([0]);
309
+
310
+ counter.count = 5;
311
+ await Promise.resolve();
312
+
313
+ expect(values).toEqual([0, 5]);
314
+
315
+ counter.count = 10;
316
+ await Promise.resolve();
317
+
318
+ expect(values).toEqual([0, 5, 10]);
319
+ });
320
+ });
321
+ });
322
+
323
+
324
+ describe('isReactiveObject', () => {
325
+ it('returns true for ReactiveObject', () => {
326
+ let obj = new ReactiveObject({ a: 1 });
327
+
328
+ expect(isReactiveObject(obj)).toBe(true);
329
+ });
330
+
331
+ it('returns false for plain objects', () => {
332
+ expect(isReactiveObject({ a: 1 })).toBe(false);
333
+ });
334
+
335
+ it('returns false for null/undefined', () => {
336
+ expect(isReactiveObject(null)).toBe(false);
337
+ expect(isReactiveObject(undefined)).toBe(false);
338
+ });
339
+
340
+ it('returns false for primitives', () => {
341
+ expect(isReactiveObject(42)).toBe(false);
342
+ expect(isReactiveObject('str')).toBe(false);
343
+ });
344
+ });
345
+
346
+
347
+ describe('reactive()', () => {
348
+ describe('objects', () => {
349
+ it('creates reactive object', () => {
350
+ let obj = reactive({ name: 'John', age: 25 });
351
+
352
+ expect((obj as any).name).toBe('John');
353
+ expect((obj as any).age).toBe(25);
354
+ });
355
+
356
+ it('properties are reactive', async () => {
357
+ let obj = reactive({ count: 0 }) as any,
358
+ values: number[] = [];
359
+
360
+ effect(() => {
361
+ values.push(obj.count);
362
+ });
363
+
364
+ obj.count = 10;
365
+ await Promise.resolve();
366
+
367
+ expect(values).toEqual([0, 10]);
368
+ });
369
+
370
+ it('has dispose method', () => {
371
+ let obj = reactive({ a: 1 }) as any;
372
+
373
+ expect(typeof obj.dispose).toBe('function');
374
+ });
375
+ });
376
+
377
+
378
+ describe('arrays', () => {
379
+ it('creates reactive array', () => {
380
+ let arr = reactive([1, 2, 3]);
381
+
382
+ expect(arr.length).toBe(3);
383
+ });
384
+ });
385
+
386
+
387
+ describe('errors', () => {
388
+ it('throws on invalid input', () => {
389
+ expect(() => reactive(42 as any)).toThrow();
390
+ expect(() => reactive('hello' as any)).toThrow();
391
+ });
392
+ });
393
+ });
394
+
395
+
396
+ describe('integration', () => {
397
+ it('computed across objects', async () => {
398
+ let a = new ReactiveObject({ value: 10 }) as any,
399
+ b = new ReactiveObject({ value: 20 }) as any,
400
+ results: number[] = [];
401
+
402
+ effect(() => {
403
+ results.push(a.value + b.value);
404
+ });
405
+
406
+ expect(results).toEqual([30]);
407
+
408
+ a.value = 100;
409
+ await Promise.resolve();
410
+
411
+ expect(results).toEqual([30, 120]);
412
+
413
+ b.value = 200;
414
+ await Promise.resolve();
415
+
416
+ expect(results).toEqual([30, 120, 300]);
417
+ });
418
+
419
+ it('nested reactive objects', async () => {
420
+ let inner = new ReactiveObject({ x: 1 }) as any;
421
+ let outer = new ReactiveObject({ child: inner }) as any;
422
+
423
+ expect(outer.child).toBe(inner);
424
+ });
425
+
426
+ it('effect with mixed signal and object dependencies', async () => {
427
+ let s = signal(1),
428
+ obj = new ReactiveObject({ count: 10 }) as any,
429
+ results: number[] = [];
430
+
431
+ effect(() => {
432
+ results.push(read(s) + obj.count);
433
+ });
434
+
435
+ expect(results).toEqual([11]);
436
+
437
+ write(s, 2);
438
+ await Promise.resolve();
439
+
440
+ expect(results).toEqual([11, 12]);
441
+
442
+ obj.count = 20;
443
+ await Promise.resolve();
444
+
445
+ expect(results).toEqual([11, 12, 22]);
446
+ });
447
+
448
+ it('diamond dependency with objects and computeds', async () => {
449
+ let base = signal(1),
450
+ left = computed(() => read(base) + 1),
451
+ right = computed(() => read(base) * 2),
452
+ results: number[] = [];
453
+
454
+ effect(() => {
455
+ results.push(read(left) + read(right));
456
+ });
457
+
458
+ expect(results).toEqual([4]);
459
+
460
+ write(base, 5);
461
+ await Promise.resolve();
462
+
463
+ expect(results).toEqual([4, 16]);
464
+ });
465
+
466
+ it('large dependency chain', async () => {
467
+ let s = signal(0),
468
+ chain: ReturnType<typeof computed>[] = [computed(() => read(s))];
469
+
470
+ for (let i = 1; i < 50; i++) {
471
+ let prev = chain[i - 1];
472
+ chain.push(computed(() => read(prev) + 1));
473
+ }
474
+
475
+ expect(read(chain[49])).toBe(49);
476
+
477
+ write(s, 10);
478
+ await Promise.resolve();
479
+
480
+ expect(read(chain[49])).toBe(59);
481
+ });
482
+
483
+ it('many signals → one computed', async () => {
484
+ let signals: ReturnType<typeof signal<number>>[] = [];
485
+
486
+ for (let i = 0; i < 100; i++) {
487
+ signals.push(signal(i));
488
+ }
489
+
490
+ let sum = computed(() => {
491
+ let total = 0;
492
+
493
+ for (let i = 0, n = signals.length; i < n; i++) {
494
+ total += read(signals[i]);
495
+ }
496
+
497
+ return total;
498
+ });
499
+
500
+ expect(read(sum)).toBe(4950);
501
+
502
+ write(signals[0], 100);
503
+ await Promise.resolve();
504
+
505
+ expect(read(sum)).toBe(5050);
506
+ });
507
+
508
+ it('effect with cleanup and re-subscribe', async () => {
509
+ let a = signal(1),
510
+ b = signal(100),
511
+ toggle = signal(true),
512
+ cleanups = 0,
513
+ values: number[] = [];
514
+
515
+ effect(() => {
516
+ onCleanup(() => { cleanups++; });
517
+
518
+ if (read(toggle)) {
519
+ values.push(read(a));
520
+ }
521
+ else {
522
+ values.push(read(b));
523
+ }
524
+ });
525
+
526
+ expect(values).toEqual([1]);
527
+
528
+ write(toggle, false);
529
+ await Promise.resolve();
530
+
531
+ expect(values).toEqual([1, 100]);
532
+ expect(cleanups).toBe(1);
533
+ });
534
+ });