@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.
@@ -0,0 +1,326 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ts } from '@esportsplus/typescript';
3
+ import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
4
+ import { NAMESPACE } from '~/compiler/constants';
5
+ import type { Bindings } from '~/compiler/types';
6
+ import array from '~/compiler/array';
7
+ import object from '~/compiler/object';
8
+ import primitives from '~/compiler/primitives';
9
+ import pipeline from '~/compiler/index';
10
+ import tscPlugin from '~/compiler/plugins/tsc';
11
+ import vitePlugin from '~/compiler/plugins/vite';
12
+
13
+
14
+ function applyIntents(code: string, sourceFile: ts.SourceFile, intents: ReplacementIntent[]): string {
15
+ let sorted = [...intents].sort((a, b) => b.node.getStart(sourceFile) - a.node.getStart(sourceFile));
16
+
17
+ for (let i = 0, n = sorted.length; i < n; i++) {
18
+ let intent = sorted[i],
19
+ end = intent.node.getEnd(),
20
+ start = intent.node.getStart(sourceFile);
21
+
22
+ code = code.slice(0, start) + intent.generate(sourceFile) + code.slice(end);
23
+ }
24
+
25
+ return code;
26
+ }
27
+
28
+ function isReactiveCall(node: ts.Node): boolean {
29
+ return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'reactive';
30
+ }
31
+
32
+ function parse(code: string): ts.SourceFile {
33
+ return ts.createSourceFile('test.ts', code, ts.ScriptTarget.Latest, true);
34
+ }
35
+
36
+ function transformPrimitives(code: string): { bindings: Bindings; output: string } {
37
+ let bindings: Bindings = new Map(),
38
+ sourceFile = parse(code),
39
+ intents = primitives(sourceFile, bindings, isReactiveCall);
40
+
41
+ return { bindings, output: applyIntents(code, sourceFile, intents) };
42
+ }
43
+
44
+ function transformArray(code: string, bindings?: Bindings): { bindings: Bindings; output: string } {
45
+ let b: Bindings = bindings ?? new Map(),
46
+ sourceFile = parse(code),
47
+ intents = array(sourceFile, b, undefined);
48
+
49
+ return { bindings: b, output: applyIntents(code, sourceFile, intents) };
50
+ }
51
+
52
+ function transformObject(code: string): { bindings: Bindings; output: string; prepend: string[] } {
53
+ let bindings: Bindings = new Map(),
54
+ sourceFile = parse(code),
55
+ result = object(sourceFile, bindings, undefined);
56
+
57
+ return {
58
+ bindings,
59
+ output: applyIntents(code, sourceFile, result.replacements),
60
+ prepend: result.prepend
61
+ };
62
+ }
63
+
64
+
65
+ describe('primitives transform', () => {
66
+ it('transforms reactive(0) to signal', () => {
67
+ let { output } = transformPrimitives('let x = reactive(0);');
68
+
69
+ expect(output).toContain(`${NAMESPACE}.signal(0)`);
70
+ });
71
+
72
+ it('transforms reactive(() => expr) to computed', () => {
73
+ let { output } = transformPrimitives('let x = reactive(0); let d = reactive(() => x * 2);');
74
+
75
+ expect(output).toContain(`${NAMESPACE}.computed(() =>`);
76
+ });
77
+
78
+ it('transforms reads to namespace read', () => {
79
+ let { output } = transformPrimitives('let x = reactive(0); console.log(x);');
80
+
81
+ expect(output).toContain(`${NAMESPACE}.read(x)`);
82
+ });
83
+
84
+ it('transforms simple assignment to write', () => {
85
+ let { output } = transformPrimitives('let x = reactive(0); x = 5;');
86
+
87
+ expect(output).toContain(`${NAMESPACE}.write(x, 5)`);
88
+ });
89
+
90
+ it('transforms compound assignment += to write', () => {
91
+ let { output } = transformPrimitives('let x = reactive(0); x += 5;');
92
+
93
+ expect(output).toContain(`${NAMESPACE}.write(x, x.value + 5)`);
94
+ });
95
+
96
+ it('transforms postfix x++ in statement to write', () => {
97
+ let { output } = transformPrimitives('let x = reactive(0); x++;');
98
+
99
+ expect(output).toContain(`${NAMESPACE}.write(x, x.value + 1)`);
100
+ });
101
+
102
+ it('transforms prefix ++x in expression', () => {
103
+ let { output } = transformPrimitives('let x = reactive(0); let y = ++x;');
104
+
105
+ expect(output).toContain(`(${NAMESPACE}.write(x, x.value + 1), x.value)`);
106
+ });
107
+
108
+ it('transforms postfix x++ in expression with temp variable', () => {
109
+ let { output } = transformPrimitives('let x = reactive(0); let y = x++;');
110
+
111
+ expect(output).toContain(`((_t0) => (${NAMESPACE}.write(x, _t0 + 1), _t0))(x.value)`);
112
+ });
113
+
114
+ it('transforms reads in nested functions within scope', () => {
115
+ let { output } = transformPrimitives('let x = reactive(0); function fn() { return x; }');
116
+
117
+ // The x inside fn IS within the reactive binding scope, so it gets transformed
118
+ expect(output).toContain(`${NAMESPACE}.read(x)`);
119
+ });
120
+
121
+ it('transforms dynamic expression to namespace reactive', () => {
122
+ let { output } = transformPrimitives('let x = reactive(someCall());');
123
+
124
+ expect(output).toContain(`${NAMESPACE}.reactive(someCall())`);
125
+ });
126
+
127
+ it('tracks bindings for signal type', () => {
128
+ let { bindings } = transformPrimitives('let x = reactive(0);');
129
+
130
+ // TYPES.Signal = 3
131
+ expect(bindings.get('x')).toBe(3);
132
+ });
133
+
134
+ it('tracks bindings for computed type', () => {
135
+ let { bindings } = transformPrimitives('let x = reactive(0); let d = reactive(() => x * 2);');
136
+
137
+ // TYPES.Computed = 1
138
+ expect(bindings.get('d')).toBe(1);
139
+ });
140
+
141
+ it('transforms prefix --x in statement', () => {
142
+ let { output } = transformPrimitives('let x = reactive(0); --x;');
143
+
144
+ expect(output).toContain(`${NAMESPACE}.write(x, x.value - 1)`);
145
+ });
146
+
147
+ it('transforms compound assignment -= to write', () => {
148
+ let { output } = transformPrimitives('let x = reactive(0); x -= 3;');
149
+
150
+ expect(output).toContain(`${NAMESPACE}.write(x, x.value - 3)`);
151
+ });
152
+ });
153
+
154
+
155
+ describe('object transform', () => {
156
+ it('transforms reactive object with signal field', () => {
157
+ let { output, prepend } = transformObject('let obj = reactive({ count: 0 });');
158
+
159
+ expect(prepend.length).toBe(1);
160
+ expect(prepend[0]).toContain(`extends ${NAMESPACE}.ReactiveObject`);
161
+ expect(prepend[0]).toContain(`${NAMESPACE}.read(this.#count)`);
162
+ expect(prepend[0]).toContain(`${NAMESPACE}.write(this.#count`);
163
+ expect(output).toContain('new ');
164
+ expect(output).not.toContain('reactive(');
165
+ });
166
+
167
+ it('transforms reactive object with array field', () => {
168
+ let { prepend } = transformObject('let obj = reactive({ items: [1, 2, 3] });');
169
+
170
+ expect(prepend.length).toBe(1);
171
+ expect(prepend[0]).toContain(`${NAMESPACE}.REACTIVE_ARRAY`);
172
+ expect(prepend[0]).toContain('get items()');
173
+ });
174
+
175
+ it('transforms reactive object with computed field', () => {
176
+ let { prepend } = transformObject('let obj = reactive({ doubled: () => 2 });');
177
+
178
+ expect(prepend.length).toBe(1);
179
+ expect(prepend[0]).toContain(`${NAMESPACE}.COMPUTED`);
180
+ expect(prepend[0]).toContain(`${NAMESPACE}.read(this.#doubled)`);
181
+ });
182
+
183
+ it('transforms reactive object with mixed properties', () => {
184
+ let { output, prepend } = transformObject(
185
+ 'let obj = reactive({ count: 0, items: [1], doubled: () => 2 });'
186
+ );
187
+
188
+ expect(prepend.length).toBe(1);
189
+ expect(prepend[0]).toContain(`${NAMESPACE}.SIGNAL`);
190
+ expect(prepend[0]).toContain(`${NAMESPACE}.REACTIVE_ARRAY`);
191
+ expect(prepend[0]).toContain(`${NAMESPACE}.COMPUTED`);
192
+ expect(output).toContain('new ');
193
+ });
194
+
195
+ it('does not transform object with spread assignment', () => {
196
+ let { output, prepend } = transformObject('let obj = reactive({ ...base, count: 0 });');
197
+
198
+ expect(prepend.length).toBe(0);
199
+ expect(output).toContain('reactive(');
200
+ });
201
+
202
+ it('preserves type parameter', () => {
203
+ let { output } = transformObject('let obj = reactive<MyType>({ count: 0 });');
204
+
205
+ expect(output).toContain('<MyType>');
206
+ });
207
+
208
+ it('tracks object binding', () => {
209
+ let { bindings } = transformObject('let obj = reactive({ count: 0 });');
210
+
211
+ // TYPES.Object = 2
212
+ expect(bindings.get('obj')).toBe(2);
213
+ });
214
+
215
+ it('tracks nested array bindings', () => {
216
+ let { bindings } = transformObject('let obj = reactive({ items: [1, 2, 3] });');
217
+
218
+ // TYPES.Array = 0
219
+ expect(bindings.get('obj.items')).toBe(0);
220
+ });
221
+ });
222
+
223
+
224
+ describe('array transform', () => {
225
+ it('transforms reactive([1,2,3]) to ReactiveArray', () => {
226
+ let { output } = transformArray('let arr = reactive([1, 2, 3]);');
227
+
228
+ expect(output).toContain(`new ${NAMESPACE}.ReactiveArray`);
229
+ expect(output).toContain('...[1, 2, 3]');
230
+ });
231
+
232
+ it('transforms reactive([] as Type[]) to typed ReactiveArray', () => {
233
+ let { output } = transformArray('let arr = reactive([] as number[]);');
234
+
235
+ expect(output).toContain(`new ${NAMESPACE}.ReactiveArray<number>()`);
236
+ });
237
+
238
+ it('transforms arr.length read to arr.$length', () => {
239
+ let bindings: Bindings = new Map();
240
+
241
+ bindings.set('arr', 0); // TYPES.Array = 0
242
+ let { output } = transformArray('let x = arr.length;', bindings);
243
+
244
+ expect(output).toContain('arr.$length');
245
+ });
246
+
247
+ it('transforms arr.length = n to arr.$length = n', () => {
248
+ let bindings: Bindings = new Map();
249
+
250
+ bindings.set('arr', 0);
251
+ let { output } = transformArray('arr.length = 5;', bindings);
252
+
253
+ expect(output).toContain('arr.$length = 5');
254
+ });
255
+
256
+ it('transforms arr.length += n to arr.$length = arr.length + n', () => {
257
+ let bindings: Bindings = new Map();
258
+
259
+ bindings.set('arr', 0);
260
+ let { output } = transformArray('arr.length += 3;', bindings);
261
+
262
+ expect(output).toContain('arr.$length = arr.length + 3');
263
+ });
264
+
265
+ it('transforms arr[i] = value to arr.$set(i, value)', () => {
266
+ let bindings: Bindings = new Map();
267
+
268
+ bindings.set('arr', 0);
269
+ let { output } = transformArray('arr[0] = 42;', bindings);
270
+
271
+ expect(output).toContain('arr.$set(');
272
+ expect(output).toContain('42');
273
+ });
274
+
275
+ it('tracks reactive array binding from reactive call', () => {
276
+ let { bindings } = transformArray('let arr = reactive([1, 2, 3]);');
277
+
278
+ // TYPES.Array = 0
279
+ expect(bindings.get('arr')).toBe(0);
280
+ });
281
+
282
+ it('tracks alias binding from reactive array', () => {
283
+ let bindings: Bindings = new Map();
284
+
285
+ bindings.set('a', 0);
286
+ transformArray('let b = a;', bindings);
287
+
288
+ expect(bindings.get('b')).toBe(0);
289
+ });
290
+
291
+ it('tracks typed parameter as ReactiveArray', () => {
292
+ let bindings: Bindings = new Map();
293
+
294
+ transformArray('function fn(arr: ReactiveArray) { return arr; }', bindings);
295
+
296
+ expect(bindings.get('arr')).toBe(0);
297
+ });
298
+
299
+ it('transforms empty array', () => {
300
+ let { output } = transformArray('let arr = reactive([] as string[]);');
301
+
302
+ expect(output).toContain(`new ${NAMESPACE}.ReactiveArray<string>()`);
303
+ });
304
+ });
305
+
306
+
307
+ describe('index transform', () => {
308
+ it('exports patterns array', () => {
309
+ expect(pipeline.patterns).toEqual(['reactive(', 'reactive<']);
310
+ });
311
+
312
+ it('has transform function', () => {
313
+ expect(typeof pipeline.transform).toBe('function');
314
+ });
315
+ });
316
+
317
+
318
+ describe('plugins', () => {
319
+ it('tsc plugin is defined', () => {
320
+ expect(tscPlugin).toBeDefined();
321
+ });
322
+
323
+ it('vite plugin is defined', () => {
324
+ expect(vitePlugin).toBeDefined();
325
+ });
326
+ });
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