@esportsplus/reactivity 0.37.0 → 0.37.2

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.
@@ -5,6 +5,7 @@ import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
5
5
  import { NAMESPACE, TYPES } from '~/compiler/constants';
6
6
  import type { Bindings } from '~/compiler/types';
7
7
  import array from '~/compiler/array';
8
+ import scope from '~/compiler/bindings';
8
9
  import object from '~/compiler/object';
9
10
  import primitives from '~/compiler/primitives';
10
11
  import pipeline from '~/compiler/index';
@@ -30,29 +31,39 @@ function isReactiveCall(node: ts.Node): node is ts.CallExpression {
30
31
  return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'reactive';
31
32
  }
32
33
 
33
- function parse(code: string): ts.SourceFile {
34
- return languageService.parse(process.cwd() + '/test.ts', code);
34
+ // Bindings resolve by symbol, so every transform needs a checker over the parsed source
35
+ function parse(code: string): { bindings: Bindings; sourceFile: ts.SourceFile } {
36
+ let { checker, sourceFile } = languageService.scratch(process.cwd() + '/test.ts', code);
37
+
38
+ return { bindings: scope.create(checker), sourceFile };
39
+ }
40
+
41
+ // Full pipeline over a module importing reactive(): what a real consumer compiles
42
+ function transformModule(code: string): string {
43
+ code = `import { reactive } from '@esportsplus/reactivity';\n` + code;
44
+
45
+ let { checker, sourceFile } = languageService.scratch(process.cwd() + '/module.ts', code),
46
+ result = pipeline.transform({ checker, code, sourceFile } as never);
47
+
48
+ return applyIntents(code, sourceFile, result.replacements ?? []);
35
49
  }
36
50
 
37
51
  function transformPrimitives(code: string): { bindings: Bindings; output: string } {
38
- let bindings: Bindings = new Map(),
39
- sourceFile = parse(code),
52
+ let { bindings, sourceFile } = parse(code),
40
53
  { replacements } = primitives(sourceFile, bindings, isReactiveCall);
41
54
 
42
55
  return { bindings, output: applyIntents(code, sourceFile, replacements) };
43
56
  }
44
57
 
45
- function transformArray(code: string, bindings?: Bindings): { bindings: Bindings; output: string } {
46
- let b: Bindings = bindings ?? new Map(),
47
- sourceFile = parse(code),
48
- intents = array(sourceFile, b, isReactiveCall);
58
+ function transformArray(code: string): { bindings: Bindings; output: string } {
59
+ let { bindings, sourceFile } = parse(code),
60
+ intents = array(sourceFile, bindings, isReactiveCall);
49
61
 
50
- return { bindings: b, output: applyIntents(code, sourceFile, intents) };
62
+ return { bindings, output: applyIntents(code, sourceFile, intents) };
51
63
  }
52
64
 
53
65
  function transformObject(code: string): { bindings: Bindings; output: string; prepend: string[] } {
54
- let bindings: Bindings = new Map(),
55
- sourceFile = parse(code),
66
+ let { bindings, sourceFile } = parse(code),
56
67
  result = object(sourceFile, bindings, isReactiveCall);
57
68
 
58
69
  return {
@@ -62,6 +73,26 @@ function transformObject(code: string): { bindings: Bindings; output: string; pr
62
73
  };
63
74
  }
64
75
 
76
+ function typeOf(bindings: Bindings, name: string): number | undefined {
77
+ for (let [symbol, type] of bindings.symbols) {
78
+ if (symbol.name === name) {
79
+ return type;
80
+ }
81
+ }
82
+
83
+ return undefined;
84
+ }
85
+
86
+ function hasPath(bindings: Bindings, name: string, path: string): boolean {
87
+ for (let [symbol, paths] of bindings.paths) {
88
+ if (symbol.name === name && paths.has(path)) {
89
+ return true;
90
+ }
91
+ }
92
+
93
+ return false;
94
+ }
95
+
65
96
 
66
97
  describe('primitives transform', () => {
67
98
  it('transforms reactive(0) to signal', () => {
@@ -128,13 +159,13 @@ describe('primitives transform', () => {
128
159
  it('tracks bindings for signal type', () => {
129
160
  let { bindings } = transformPrimitives('let x = reactive(0);');
130
161
 
131
- expect(bindings.get('x')).toBe(TYPES.Signal);
162
+ expect(typeOf(bindings, 'x')).toBe(TYPES.Signal);
132
163
  });
133
164
 
134
165
  it('tracks bindings for computed type', () => {
135
166
  let { bindings } = transformPrimitives('let x = reactive(0); let d = reactive(() => x * 2);');
136
167
 
137
- expect(bindings.get('d')).toBe(TYPES.Computed);
168
+ expect(typeOf(bindings, 'd')).toBe(TYPES.Computed);
138
169
  });
139
170
 
140
171
  it('leaves a same-named plain variable in a sibling function untouched', () => {
@@ -222,7 +253,7 @@ describe('object transform', () => {
222
253
  it('tracks nested array bindings', () => {
223
254
  let { bindings } = transformObject('let obj = reactive({ items: [1, 2, 3] });');
224
255
 
225
- expect(bindings.get('obj.items')).toBe(TYPES.Array);
256
+ expect(hasPath(bindings, 'obj', 'items')).toBe(true);
226
257
  });
227
258
  });
228
259
 
@@ -242,49 +273,33 @@ describe('array transform', () => {
242
273
  });
243
274
 
244
275
  it('transforms arr.length read to arr.$length', () => {
245
- let bindings: Bindings = new Map();
246
-
247
- bindings.set('arr', 0); // TYPES.Array = 0
248
- let { output } = transformArray('let x = arr.length;', bindings);
276
+ let { output } = transformArray('let arr = reactive([1]); let x = arr.length;');
249
277
 
250
278
  expect(output).toContain('arr.$length');
251
279
  });
252
280
 
253
281
  it('transforms arr.length = n to arr.$length = n', () => {
254
- let bindings: Bindings = new Map();
255
-
256
- bindings.set('arr', 0);
257
- let { output } = transformArray('arr.length = 5;', bindings);
282
+ let { output } = transformArray('let arr = reactive([1]); arr.length = 5;');
258
283
 
259
284
  expect(output).toContain('arr.$length = 5');
260
285
  });
261
286
 
262
287
  it('transforms arr.length += n to arr.$length = arr.length + n', () => {
263
- let bindings: Bindings = new Map();
264
-
265
- bindings.set('arr', 0);
266
- let { output } = transformArray('arr.length += 3;', bindings);
288
+ let { output } = transformArray('let arr = reactive([1]); arr.length += 3;');
267
289
 
268
290
  expect(output).toContain('arr.$length = arr.length + 3');
269
291
  });
270
292
 
271
293
  it('transforms every compound operator on arr.length with its own token', () => {
272
- let bindings: Bindings = new Map();
273
-
274
- bindings.set('arr', 0);
275
-
276
294
  for (let op of ['<<', '>>', '>>>', '??', '||', '&&', '-', '*', '/', '%', '**', '&', '|', '^']) {
277
- let { output } = transformArray(`arr.length ${op}= 2;`, bindings);
295
+ let { output } = transformArray(`let arr = reactive([1]); arr.length ${op}= 2;`);
278
296
 
279
297
  expect(output).toContain(`arr.$length = arr.length ${op} 2`);
280
298
  }
281
299
  });
282
300
 
283
301
  it('transforms arr[i] = value to arr.$set(i, value)', () => {
284
- let bindings: Bindings = new Map();
285
-
286
- bindings.set('arr', 0);
287
- let { output } = transformArray('arr[0] = 42;', bindings);
302
+ let { output } = transformArray('let arr = reactive([1]); arr[0] = 42;');
288
303
 
289
304
  expect(output).toContain('arr.$set(');
290
305
  expect(output).toContain('42');
@@ -293,24 +308,19 @@ describe('array transform', () => {
293
308
  it('tracks reactive array binding from reactive call', () => {
294
309
  let { bindings } = transformArray('let arr = reactive([1, 2, 3]);');
295
310
 
296
- expect(bindings.get('arr')).toBe(TYPES.Array);
311
+ expect(typeOf(bindings, 'arr')).toBe(TYPES.Array);
297
312
  });
298
313
 
299
314
  it('tracks alias binding from reactive array', () => {
300
- let bindings: Bindings = new Map();
301
-
302
- bindings.set('a', 0);
303
- transformArray('let b = a;', bindings);
315
+ let { bindings } = transformArray('let a = reactive([1]); let b = a;');
304
316
 
305
- expect(bindings.get('b')).toBe(0);
317
+ expect(typeOf(bindings, 'b')).toBe(TYPES.Array);
306
318
  });
307
319
 
308
320
  it('tracks typed parameter as ReactiveArray', () => {
309
- let bindings: Bindings = new Map();
310
-
311
- transformArray('function fn(arr: ReactiveArray) { return arr; }', bindings);
321
+ let { bindings } = transformArray('function fn(arr: ReactiveArray) { return arr; }');
312
322
 
313
- expect(bindings.get('arr')).toBe(0);
323
+ expect(typeOf(bindings, 'arr')).toBe(TYPES.Array);
314
324
  });
315
325
 
316
326
  it('transforms empty array', () => {
@@ -321,6 +331,66 @@ describe('array transform', () => {
321
331
  });
322
332
 
323
333
 
334
+ describe('binding resolution (full pipeline)', () => {
335
+ it('leaves a local array that shadows a reactive array untouched', () => {
336
+ let output = transformModule('let rows = reactive([] as number[]);\nfunction build(n: number) { let rows: number[] = []; rows[0] = n; return rows.length; }');
337
+
338
+ expect(output).toContain('let rows: number[] = []; rows[0] = n; return rows.length;');
339
+ });
340
+
341
+ it('leaves a parameter that shadows a reactive array untouched', () => {
342
+ let output = transformModule('let rows = reactive([1]);\nconst f = (rows: number[]) => { rows[0] = 2; };');
343
+
344
+ expect(output).toContain('const f = (rows: number[]) => { rows[0] = 2; };');
345
+ });
346
+
347
+ it('leaves a local that shadows a signal untouched', () => {
348
+ let output = transformModule('let count = reactive(0);\nfunction f() { let count = 5; count++; return count; }');
349
+
350
+ expect(output).toContain('function f() { let count = 5; count++; return count; }');
351
+ });
352
+
353
+ it('leaves a parameter that shadows a signal untouched', () => {
354
+ let output = transformModule('let count = reactive(0);\nconst f = (count: number) => count + 1;');
355
+
356
+ expect(output).toContain('const f = (count: number) => count + 1;');
357
+ });
358
+
359
+ it('never rewrites property names, type members or destructured parameters', () => {
360
+ let output = transformModule('let count = reactive(0);\nlet o = { count: 1 };\nlet p = o.count;\nfunction f({ count }: { count: number }) { return count; }');
361
+
362
+ expect(output).toContain('let o = { count: 1 };');
363
+ expect(output).toContain('let p = o.count;');
364
+ expect(output).toContain('function f({ count }: { count: number }) { return count; }');
365
+ });
366
+
367
+ it('expands a shorthand property into a read', () => {
368
+ let output = transformModule('let count = reactive(0);\nlet o = { count };');
369
+
370
+ expect(output).toContain(`let o = { count: ${NAMESPACE}.read(count) };`);
371
+ });
372
+
373
+ it('rewrites a use that precedes the declaration in source order', () => {
374
+ let output = transformModule('function inc() { count++; }\nlet count = reactive(0);');
375
+
376
+ expect(output).toContain(`function inc() { ${NAMESPACE}.write(count, count.value + 1); }`);
377
+ });
378
+
379
+ it('exports the signal itself rather than splicing a read into the export list', () => {
380
+ let output = transformModule('let count = reactive(0);\nexport { count };');
381
+
382
+ expect(output).toContain('export { count };');
383
+ });
384
+
385
+ it('transforms a namespace-qualified reactive() call', () => {
386
+ let output = transformModule("import * as r from '@esportsplus/reactivity';\nlet count = r.reactive(0);\nlet next = count + 1;");
387
+
388
+ expect(output).toContain(`let count = ${NAMESPACE}.signal(0);`);
389
+ expect(output).toContain(`let next = ${NAMESPACE}.read(count) + 1;`);
390
+ });
391
+ });
392
+
393
+
324
394
  describe('index transform', () => {
325
395
  it('exports patterns array', () => {
326
396
  expect(pipeline.patterns).toEqual(['reactive(', 'reactive<']);
@@ -282,19 +282,21 @@ describe('ReactiveArray', () => {
282
282
  expect(dispatched).toBe(false);
283
283
  });
284
284
 
285
- it('does not dispatch when popping explicit undefined value', () => {
285
+ // An element's value says nothing about whether one was removed: listeners (e.g. a rendered
286
+ // list) must see every removal or they desync from the array
287
+ it('dispatches when popping an explicit undefined value', () => {
286
288
  let arr = new ReactiveArray<number | undefined>([1, undefined]),
287
- dispatched = false;
289
+ events: { item: number | undefined }[] = [];
288
290
 
289
- arr.on('pop', () => { dispatched = true; });
291
+ arr.on('pop', (e) => { events.push(e); });
290
292
  let item = arr.pop();
291
293
 
292
294
  expect(item).toBe(undefined);
293
295
  expect(arr.length).toBe(1);
294
- expect(dispatched).toBe(false);
296
+ expect(events).toEqual([{ item: undefined }]);
295
297
  });
296
298
 
297
- it('does not update reactive length when popping explicit undefined value', async () => {
299
+ it('updates reactive length when popping an explicit undefined value', async () => {
298
300
  let arr = new ReactiveArray<number | undefined>([1, undefined]),
299
301
  lengths: number[] = [];
300
302
 
@@ -307,7 +309,7 @@ describe('ReactiveArray', () => {
307
309
  arr.pop();
308
310
  await Promise.resolve();
309
311
 
310
- expect(lengths).toEqual([2]);
312
+ expect(lengths).toEqual([2, 1]);
311
313
  });
312
314
  });
313
315
 
@@ -337,20 +339,20 @@ describe('ReactiveArray', () => {
337
339
  expect(events).toEqual([{ item: 10 }]);
338
340
  });
339
341
 
340
- it('does not dispatch when shifting explicit undefined value', () => {
342
+ it('dispatches when shifting an explicit undefined value', () => {
341
343
  let arr = new ReactiveArray<number | undefined>([undefined, 1, 2]),
342
- dispatched = false;
344
+ events: { item: number | undefined }[] = [];
343
345
 
344
- arr.on('shift', () => { dispatched = true; });
346
+ arr.on('shift', (e) => { events.push(e); });
345
347
  let item = arr.shift();
346
348
 
347
349
  expect(item).toBe(undefined);
348
350
  expect(arr.length).toBe(2);
349
351
  expect(arr[0]).toBe(1);
350
- expect(dispatched).toBe(false);
352
+ expect(events).toEqual([{ item: undefined }]);
351
353
  });
352
354
 
353
- it('does not update reactive length when shifting explicit undefined value', async () => {
355
+ it('updates reactive length when shifting an explicit undefined value', async () => {
354
356
  let arr = new ReactiveArray<number | undefined>([undefined, 1, 2]),
355
357
  lengths: number[] = [];
356
358
 
@@ -363,7 +365,17 @@ describe('ReactiveArray', () => {
363
365
  arr.shift();
364
366
  await Promise.resolve();
365
367
 
366
- expect(lengths).toEqual([3]);
368
+ expect(lengths).toEqual([3, 2]);
369
+ });
370
+
371
+ it('does not dispatch when shifting an empty array', () => {
372
+ let arr = new ReactiveArray<number>(),
373
+ dispatched = false;
374
+
375
+ arr.on('shift', () => { dispatched = true; });
376
+
377
+ expect(arr.shift()).toBe(undefined);
378
+ expect(dispatched).toBe(false);
367
379
  });
368
380
  });
369
381
 
@@ -477,7 +489,7 @@ describe('ReactiveArray', () => {
477
489
  expect(dispatched).toBe(false);
478
490
  });
479
491
 
480
- it('splice with negative start removes from end', () => {
492
+ it('splice with negative start dispatches the resolved start', () => {
481
493
  let arr = new ReactiveArray([1, 2, 3, 4, 5]),
482
494
  events: { start: number; deleteCount: number; items: number[] }[] = [];
483
495
 
@@ -487,7 +499,35 @@ describe('ReactiveArray', () => {
487
499
 
488
500
  expect([...removed]).toEqual([4]);
489
501
  expect([...arr]).toEqual([1, 2, 3, 5]);
490
- expect(events).toEqual([{ start: -2, deleteCount: 1, items: [] }]);
502
+ expect(events).toEqual([{ start: 3, deleteCount: 1, items: [] }]);
503
+ });
504
+
505
+ it('splice dispatches the count actually removed, not the requested count', () => {
506
+ let arr = new ReactiveArray([1, 2, 3]),
507
+ events: { start: number; deleteCount: number; items: number[] }[] = [];
508
+
509
+ arr.on('splice', (e) => { events.push(e); });
510
+ arr.splice(1, 100);
511
+
512
+ expect([...arr]).toEqual([1]);
513
+ expect(events).toEqual([{ start: 1, deleteCount: 2, items: [] }]);
514
+ });
515
+
516
+ it('splice clamps an out-of-range or fractional start the way Array.prototype.splice does', () => {
517
+ let arr = new ReactiveArray([1, 2, 3]),
518
+ events: { start: number; deleteCount: number; items: number[] }[] = [];
519
+
520
+ arr.on('splice', (e) => { events.push(e); });
521
+ arr.splice(100, 0, 4);
522
+ arr.splice(-100, 0, 0);
523
+ arr.splice(1.9, 0, 9);
524
+
525
+ expect([...arr]).toEqual([0, 9, 1, 2, 3, 4]);
526
+ expect(events).toEqual([
527
+ { start: 3, deleteCount: 0, items: [4] },
528
+ { start: 0, deleteCount: 0, items: [0] },
529
+ { start: 1, deleteCount: 0, items: [9] }
530
+ ]);
491
531
  });
492
532
  });
493
533
 
package/vitest.config.ts CHANGED
@@ -5,8 +5,8 @@ import { resolve } from 'path';
5
5
  export default defineConfig({
6
6
  resolve: {
7
7
  alias: {
8
- '~': resolve(__dirname, 'src'),
9
- '@esportsplus/reactivity': resolve(__dirname, 'src/index.ts')
8
+ '~': resolve(import.meta.dirname, 'src'),
9
+ '@esportsplus/reactivity': resolve(import.meta.dirname, 'src/index.ts')
10
10
  }
11
11
  },
12
12
  test: {