@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/objects.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { computed, effect, read, signal, write } from '~/system';
3
+ import { COMPUTED, REACTIVE_ARRAY } from '~/constants';
3
4
  import { ReactiveObject } from '~/reactive/object';
4
5
  import { ReactiveArray } from '~/reactive/array';
5
6
  import reactive from '~/reactive/index';
@@ -19,6 +20,15 @@ describe('reactive object patterns', () => {
19
20
  expect(obj.name).toBe('John');
20
21
  });
21
22
 
23
+ it('empty object constructor', () => {
24
+ let obj = new ReactiveObject({}) as any;
25
+
26
+ // No reactive properties created — only internal 'disposers' field
27
+ expect(Object.keys(obj).filter((k: string) => k !== 'disposers')).toEqual([]);
28
+
29
+ obj.dispose();
30
+ });
31
+
22
32
  it('updates multiple properties independently', async () => {
23
33
  let obj = new ReactiveObject({ age: 25, name: 'John' }) as any,
24
34
  ages: number[] = [],
@@ -160,6 +170,65 @@ describe('reactive object patterns', () => {
160
170
  });
161
171
 
162
172
 
173
+ describe('subclass overrides', () => {
174
+ it('[COMPUTED] subclass creates computed field from external signal', async () => {
175
+ class Doubled extends ReactiveObject<Record<string, never>> {
176
+ private _doubled: ReturnType<typeof computed<number>>;
177
+
178
+ constructor(source: ReturnType<typeof signal<number>>) {
179
+ super(null);
180
+ this._doubled = this[COMPUTED](() => read(source) * 2);
181
+ }
182
+
183
+ get doubled() {
184
+ return read(this._doubled);
185
+ }
186
+ }
187
+
188
+ let s = signal(5),
189
+ obj = new Doubled(s),
190
+ values: number[] = [];
191
+
192
+ expect(obj.doubled).toBe(10);
193
+
194
+ effect(() => {
195
+ values.push(obj.doubled);
196
+ });
197
+
198
+ expect(values).toEqual([10]);
199
+
200
+ write(s, 10);
201
+ await Promise.resolve();
202
+
203
+ expect(obj.doubled).toBe(20);
204
+ expect(values).toEqual([10, 20]);
205
+
206
+ obj.dispose();
207
+ });
208
+
209
+ it('[REACTIVE_ARRAY] subclass creates ReactiveArray field', () => {
210
+ class Collection extends ReactiveObject<Record<string, never>> {
211
+ items: ReactiveArray<number>;
212
+
213
+ constructor(values: number[]) {
214
+ super(null);
215
+ this.items = this[REACTIVE_ARRAY](values);
216
+ }
217
+ }
218
+
219
+ let obj = new Collection([10, 20, 30]);
220
+
221
+ expect(obj.items).toBeInstanceOf(ReactiveArray);
222
+ expect([...obj.items]).toEqual([10, 20, 30]);
223
+
224
+ obj.dispose();
225
+
226
+ // After dispose, array should be cleared
227
+ expect(obj.items.length).toBe(0);
228
+ });
229
+ });
230
+
231
+
163
232
  describe('dispose', () => {
164
233
  it('disposes object with computed properties', () => {
165
234
  let s = signal(42),
@@ -181,5 +250,20 @@ describe('reactive object patterns', () => {
181
250
 
182
251
  obj.dispose();
183
252
  });
253
+
254
+ it('calling dispose twice does not throw', () => {
255
+ let s = signal(1),
256
+ obj = new ReactiveObject({
257
+ computed: () => read(s) * 2,
258
+ items: [1, 2, 3]
259
+ }) as any;
260
+
261
+ expect(obj.computed).toBe(2);
262
+ expect([...obj.items]).toEqual([1, 2, 3]);
263
+
264
+ obj.dispose();
265
+
266
+ expect(() => obj.dispose()).not.toThrow();
267
+ });
184
268
  });
185
269
  });
package/tests/reactive.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { computed, effect, onCleanup, read, root, signal, write } from '~/system';
3
+ import { SIGNAL } from '~/constants';
3
4
  import { ReactiveObject, isReactiveObject } from '~/reactive/object';
4
5
  import reactive from '~/reactive/index';
5
6
  import { ReactiveArray } from '~/reactive/array';
@@ -108,6 +109,215 @@ describe('ReactiveObject', () => {
108
109
  obj.dispose();
109
110
  });
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
+ });
111
321
  });
112
322
 
113
323