@esportsplus/reactivity 0.37.2 → 0.38.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.
@@ -27,43 +27,47 @@ function applyIntents(code: string, sourceFile: ts.SourceFile, intents: Replacem
27
27
  return code;
28
28
  }
29
29
 
30
- function isReactiveCall(node: ts.Node): node is ts.CallExpression {
31
- return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'reactive';
32
- }
30
+ // The unit transforms run on real modules: `reactive` is resolved through the import like any consumer's
31
+ const PRELUDE = "import { reactive, ReactiveArray } from '@esportsplus/reactivity';\n";
32
+
33
33
 
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);
34
+ function parse(code: string): { bindings: Bindings; code: string; isReactiveCall: (node: ts.Node) => node is ts.CallExpression; sourceFile: ts.SourceFile } {
35
+ code = PRELUDE + code;
37
36
 
38
- return { bindings: scope.create(checker), sourceFile };
37
+ let { checker, program, sourceFile } = languageService.scratch(process.cwd() + '/test.ts', code),
38
+ bindings = scope.create(checker, program, sourceFile);
39
+
40
+ return { bindings, code, isReactiveCall: (node): node is ts.CallExpression => scope.isReactiveCall(bindings, node), sourceFile };
39
41
  }
40
42
 
41
43
  // Full pipeline over a module importing reactive(): what a real consumer compiles
42
44
  function transformModule(code: string): string {
43
- code = `import { reactive } from '@esportsplus/reactivity';\n` + code;
45
+ return transformSource(`import { reactive } from '@esportsplus/reactivity';\n` + code);
46
+ }
44
47
 
45
- let { checker, sourceFile } = languageService.scratch(process.cwd() + '/module.ts', code),
46
- result = pipeline.transform({ checker, code, sourceFile } as never);
48
+ function transformSource(code: string): string {
49
+ let { checker, program, sourceFile } = languageService.scratch(process.cwd() + '/module.ts', code),
50
+ result = pipeline.transform({ checker, code, program, sourceFile } as never);
47
51
 
48
52
  return applyIntents(code, sourceFile, result.replacements ?? []);
49
53
  }
50
54
 
51
- function transformPrimitives(code: string): { bindings: Bindings; output: string } {
52
- let { bindings, sourceFile } = parse(code),
53
- { replacements } = primitives(sourceFile, bindings, isReactiveCall);
55
+ function transformPrimitives(source: string): { bindings: Bindings; failures: string[]; output: string } {
56
+ let { bindings, code, isReactiveCall, sourceFile } = parse(source),
57
+ { failures, replacements } = primitives(sourceFile, bindings, isReactiveCall);
54
58
 
55
- return { bindings, output: applyIntents(code, sourceFile, replacements) };
59
+ return { bindings, failures: failures.map(f => f.message), output: applyIntents(code, sourceFile, replacements) };
56
60
  }
57
61
 
58
- function transformArray(code: string): { bindings: Bindings; output: string } {
59
- let { bindings, sourceFile } = parse(code),
60
- intents = array(sourceFile, bindings, isReactiveCall);
62
+ function transformArray(source: string): { bindings: Bindings; output: string } {
63
+ let { bindings, code, isReactiveCall, sourceFile } = parse(source),
64
+ { replacements } = array(sourceFile, bindings, isReactiveCall);
61
65
 
62
- return { bindings, output: applyIntents(code, sourceFile, intents) };
66
+ return { bindings, output: applyIntents(code, sourceFile, replacements) };
63
67
  }
64
68
 
65
- function transformObject(code: string): { bindings: Bindings; output: string; prepend: string[] } {
66
- let { bindings, sourceFile } = parse(code),
69
+ function transformObject(source: string): { bindings: Bindings; output: string; prepend: string[] } {
70
+ let { bindings, code, isReactiveCall, sourceFile } = parse(source),
67
71
  result = object(sourceFile, bindings, isReactiveCall);
68
72
 
69
73
  return {
@@ -73,24 +77,20 @@ function transformObject(code: string): { bindings: Bindings; output: string; pr
73
77
  };
74
78
  }
75
79
 
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
- }
80
+ // Kind of the binding the variable `name` declares
81
+ function typeOf(bindings: Bindings, name: string): number | null {
82
+ let found: ts.Identifier | undefined,
83
+ visit = (node: ts.Node): void => {
84
+ if (!found && ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) {
85
+ found = node.name;
86
+ }
82
87
 
83
- return undefined;
84
- }
88
+ node.forEachChild(visit);
89
+ };
85
90
 
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
- }
91
+ visit(bindings.sourceFile);
92
92
 
93
- return false;
93
+ return found ? scope.kind(bindings, scope.origin(bindings, found)) : null;
94
94
  }
95
95
 
96
96
 
@@ -150,12 +150,63 @@ describe('primitives transform', () => {
150
150
  expect(output).toContain(`${NAMESPACE}.read(x)`);
151
151
  });
152
152
 
153
- it('transforms dynamic expression to namespace reactive', () => {
154
- let { output } = transformPrimitives('let x = reactive(someCall());');
153
+ it('builds a non-literal array or object argument with the runtime reactive()', () => {
154
+ let { output } = transformPrimitives('declare function someCall(): number[];\nlet x = reactive(someCall());');
155
155
 
156
156
  expect(output).toContain(`${NAMESPACE}.reactive(someCall())`);
157
157
  });
158
158
 
159
+ it('classifies a non-literal primitive argument as a signal by its type', () => {
160
+ let { output } = transformPrimitives('declare let n: number;\nlet x = reactive(n);\nlet y = x + 1;');
161
+
162
+ expect(output).toContain(`let x = ${NAMESPACE}.signal(n)`);
163
+ expect(output).toContain(`let y = ${NAMESPACE}.read(x) + 1`);
164
+ });
165
+
166
+ it('classifies a function-typed argument as a computed', () => {
167
+ let { output } = transformPrimitives('declare function total(): number;\nlet x = reactive(total);');
168
+
169
+ expect(output).toContain(`let x = ${NAMESPACE}.computed(total)`);
170
+ });
171
+
172
+ it('rejects an argument whose kind is not provable', () => {
173
+ let { failures } = transformPrimitives('declare let value: any;\nlet x = reactive(value);');
174
+
175
+ expect(failures).toHaveLength(1);
176
+ expect(failures[0]).toContain('provable');
177
+ });
178
+
179
+ it('rejects a signal that does not initialize a variable', () => {
180
+ let { failures } = transformPrimitives('declare function take(value: unknown): void;\ntake(reactive(0));\nlet o = { count: reactive(1) };');
181
+
182
+ expect(failures).toHaveLength(2);
183
+ expect(failures[0]).toContain('must initialize a variable');
184
+ });
185
+
186
+ it('rejects a write to a computed', () => {
187
+ let { failures } = transformPrimitives('let d = reactive(() => 1);\nd = 2;');
188
+
189
+ expect(failures).toEqual(['a reactive computed is read-only']);
190
+ });
191
+
192
+ it('rejects a destructuring assignment into a reactive binding', () => {
193
+ let { failures } = transformPrimitives('let x = reactive(0);\n[x] = [1];');
194
+
195
+ expect(failures[0]).toContain('destructuring');
196
+ });
197
+
198
+ it('reads a binding assigned with reactive() after its declaration', () => {
199
+ let { output } = transformPrimitives('let x: number;\nx = reactive(0);\nlet y = x + 1;');
200
+
201
+ expect(output).toContain(`let y = ${NAMESPACE}.read(x) + 1`);
202
+ });
203
+
204
+ it('reads through typeof', () => {
205
+ let { output } = transformPrimitives("let x = reactive(0);\nlet t = typeof x;");
206
+
207
+ expect(output).toContain(`let t = typeof ${NAMESPACE}.read(x)`);
208
+ });
209
+
159
210
  it('tracks bindings for signal type', () => {
160
211
  let { bindings } = transformPrimitives('let x = reactive(0);');
161
212
 
@@ -250,10 +301,16 @@ describe('object transform', () => {
250
301
  expect(output).toContain('<MyType>');
251
302
  });
252
303
 
253
- it('tracks nested array bindings', () => {
254
- let { bindings } = transformObject('let obj = reactive({ items: [1, 2, 3] });');
304
+ it('passes every array property to the constructor, static elements included', () => {
305
+ let { output } = transformObject('let obj = reactive({ n: 1, items: [1, 2] });');
306
+
307
+ expect(output).toMatch(/new ReactiveObject_\w+\(\[1, 2\]\)/);
308
+ });
309
+
310
+ it('makes a non-literal array property a reactive array', () => {
311
+ let { prepend } = transformObject('declare let list: number[];\nlet obj = reactive({ items: list });');
255
312
 
256
- expect(hasPath(bindings, 'obj', 'items')).toBe(true);
313
+ expect(prepend[0]).toContain(`${NAMESPACE}.REACTIVE_ARRAY`);
257
314
  });
258
315
  });
259
316
 
@@ -311,16 +368,36 @@ describe('array transform', () => {
311
368
  expect(typeOf(bindings, 'arr')).toBe(TYPES.Array);
312
369
  });
313
370
 
314
- it('tracks alias binding from reactive array', () => {
315
- let { bindings } = transformArray('let a = reactive([1]); let b = a;');
371
+ it('compiles an alias of a reactive array by its type', () => {
372
+ let { output } = transformArray('let a = reactive([1]); let b = a; let n = b.length;');
316
373
 
317
- expect(typeOf(bindings, 'b')).toBe(TYPES.Array);
374
+ expect(output).toContain('let n = b.$length');
318
375
  });
319
376
 
320
- it('tracks typed parameter as ReactiveArray', () => {
321
- let { bindings } = transformArray('function fn(arr: ReactiveArray) { return arr; }');
377
+ it('compiles a ReactiveArray-typed parameter by its type', () => {
378
+ let { output } = transformArray('function fn(arr: ReactiveArray<number>) { arr[0] = 1; return arr.length; }');
322
379
 
323
- expect(typeOf(bindings, 'arr')).toBe(TYPES.Array);
380
+ expect(output).toContain('arr.$set(0, 1)');
381
+ expect(output).toContain('return arr.$length');
382
+ });
383
+
384
+ it('compiles an array property of a reactive object by its type', () => {
385
+ let { output } = transformArray('let obj = reactive({ items: [1] }); let n = obj.items.length;');
386
+
387
+ expect(output).toContain('let n = obj.items.$length');
388
+ });
389
+
390
+ it('routes compound and increment element writes through $set', () => {
391
+ let { output } = transformArray('let arr = reactive([1]); arr[0] += 2; arr[0]++;');
392
+
393
+ expect(output).toContain('((_a, _k) => _a.$set(_k, _a[_k] + (2)))(arr, 0)');
394
+ expect(output).toContain('((_a, _k, _v) => (_a.$set(_k, _v + 1), _v))(arr, 0, arr[0])');
395
+ });
396
+
397
+ it('leaves a plain array untouched', () => {
398
+ let { output } = transformArray('let arr = [1]; arr[0] = 2; let n = arr.length;');
399
+
400
+ expect(output).toContain('arr[0] = 2; let n = arr.length;');
324
401
  });
325
402
 
326
403
  it('transforms empty array', () => {
@@ -388,12 +465,39 @@ describe('binding resolution (full pipeline)', () => {
388
465
  expect(output).toContain(`let count = ${NAMESPACE}.signal(0);`);
389
466
  expect(output).toContain(`let next = ${NAMESPACE}.read(count) + 1;`);
390
467
  });
468
+
469
+ it('transforms calls through an aliased import', () => {
470
+ let output = transformSource("import { reactive as r } from '@esportsplus/reactivity';\nlet count = r(0);\nlet next = count + 1;");
471
+
472
+ expect(output).toContain(`let count = ${NAMESPACE}.signal(0);`);
473
+ expect(output).toContain(`let next = ${NAMESPACE}.read(count) + 1;`);
474
+ });
475
+
476
+ it('transforms a reactive array created through an aliased import', () => {
477
+ let output = transformSource("import { reactive as r } from '@esportsplus/reactivity';\nlet list = r([1, 2]);\nlet size = list.length;");
478
+
479
+ expect(output).not.toContain('r([1, 2])');
480
+ expect(output).toContain('let size = list.$length');
481
+ });
482
+
483
+ it('leaves a parameter that shadows an alias untouched', () => {
484
+ let code = "import { reactive as r } from '@esportsplus/reactivity';\nfunction f(r: (n: number) => number) { return r(1); }";
485
+
486
+ expect(transformSource(code)).toBe(code);
487
+ });
488
+
489
+ it('leaves another export imported under the name reactive untouched', () => {
490
+ let code = "import { root as reactive } from '@esportsplus/reactivity';\nlet value = reactive(() => 1);";
491
+
492
+ expect(transformSource(code)).toBe(code);
493
+ });
391
494
  });
392
495
 
393
496
 
394
497
  describe('index transform', () => {
395
- it('exports patterns array', () => {
396
- expect(pipeline.patterns).toEqual(['reactive(', 'reactive<']);
498
+ // A module can reach reactive() or read a binding under any name, so no text prefilter may skip it
499
+ it('has no text patterns', () => {
500
+ expect('patterns' in pipeline).toBe(false);
397
501
  });
398
502
 
399
503
  it('has transform function', () => {
@@ -0,0 +1,164 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { NAMESPACE } from '~/compiler/constants';
5
+ import vite from '~/compiler/plugins/vite';
6
+
7
+
8
+ // Every route by which a module reaches `reactive`, or a binding it created, must compile
9
+ const FILES: Record<string, string> = {
10
+ 'state.ts': [
11
+ "import { reactive } from '@esportsplus/reactivity';",
12
+ 'export let count = reactive(0);',
13
+ 'export let list = reactive([1, 2]);',
14
+ 'export let store = reactive({ n: 1, items: [1] });',
15
+ 'export default reactive(0);'
16
+ ].join('\n'),
17
+ 'barrel.ts': [
18
+ "export { reactive as make } from '@esportsplus/reactivity';",
19
+ "export * as R from '@esportsplus/reactivity';",
20
+ "export * from './state';"
21
+ ].join('\n'),
22
+ 'reads.ts': [
23
+ "import { count, list, store } from './state';",
24
+ "import * as S from './state';",
25
+ "import fallback from './state';",
26
+ "import { count as renamed } from './barrel';",
27
+ 'export let a = () => count + 1;',
28
+ 'export let b = () => S.count + 1;',
29
+ 'export let c = () => fallback + 1;',
30
+ 'export let d = () => renamed + 1;',
31
+ 'export let e = () => list.length;',
32
+ 'export let f = () => { list[0] = 5; };',
33
+ 'export let g = () => store.items.length;',
34
+ "export let h = () => S['count'] + 1;"
35
+ ].join('\n'),
36
+ 'entries.ts': [
37
+ "import { make, R } from './barrel';",
38
+ "import * as B from './barrel';",
39
+ 'export let a = make(0);',
40
+ 'export let b = R.reactive(0);',
41
+ 'export let c = B.R.reactive(0);',
42
+ "export let d = R['reactive'](0);",
43
+ 'const alias = make;',
44
+ 'export let e = alias(0);',
45
+ 'const { reactive: destructured } = R;',
46
+ 'export let f = destructured(0);',
47
+ 'export let g = () => a + b + c + d + e + f;'
48
+ ].join('\n')
49
+ };
50
+
51
+
52
+ let fixtures: string[] = [];
53
+
54
+
55
+ function project(files: Record<string, string>) {
56
+ let directory = mkdtempSync(join(process.cwd(), '.fixture-coverage-')).replace(/\\/g, '/');
57
+
58
+ fixtures.push(directory);
59
+
60
+ for (let [name, code] of Object.entries(files)) {
61
+ writeFileSync(join(directory, name), code);
62
+ }
63
+
64
+ writeFileSync(join(directory, 'tsconfig.json'), JSON.stringify({
65
+ compilerOptions: { module: 'esnext', moduleResolution: 'bundler', strict: true, target: 'esnext', types: [] },
66
+ files: Object.keys(files).map(name => './' + name)
67
+ }));
68
+
69
+ let plugin = vite({ root: directory });
70
+
71
+ plugin.configResolved({ command: 'build', root: directory });
72
+
73
+ return {
74
+ directory,
75
+ plugin,
76
+ transform: (name: string) => plugin.transform(files[name], directory + '/' + name)
77
+ };
78
+ }
79
+
80
+
81
+ afterEach(() => {
82
+ for (let fixture of fixtures) {
83
+ rmSync(fixture, { force: true, recursive: true });
84
+ }
85
+
86
+ fixtures = [];
87
+ });
88
+
89
+
90
+ describe('compiler coverage', () => {
91
+ it('compiles reads of bindings imported by name, namespace, default, barrel and string key', () => {
92
+ let output = project(FILES).transform('reads.ts')!.code;
93
+
94
+ expect(output).toContain(`export let a = () => ${NAMESPACE}.read(count) + 1;`);
95
+ expect(output).toContain(`export let b = () => ${NAMESPACE}.read(S.count) + 1;`);
96
+ expect(output).toContain(`export let c = () => ${NAMESPACE}.read(fallback) + 1;`);
97
+ expect(output).toContain(`export let d = () => ${NAMESPACE}.read(renamed) + 1;`);
98
+ expect(output).toContain('export let e = () => list.$length;');
99
+ expect(output).toContain('list.$set(0, 5)');
100
+ expect(output).toContain('export let g = () => store.items.$length;');
101
+ expect(output).toContain(`export let h = () => ${NAMESPACE}.read(S['count']) + 1;`);
102
+ });
103
+
104
+ it('compiles reactive() reached through renames, namespaces, string keys and const aliases', () => {
105
+ let output = project(FILES).transform('entries.ts')!.code;
106
+
107
+ for (let name of ['a', 'b', 'c', 'd', 'e', 'f']) {
108
+ expect(output).toMatch(new RegExp(`export let ${name} = ${NAMESPACE}\\.signal\\(0\\);`));
109
+ }
110
+
111
+ expect(output).toContain(`${NAMESPACE}.read(a) + ${NAMESPACE}.read(b)`);
112
+ });
113
+
114
+ it('passes every array property of a reactive object to its constructor', () => {
115
+ let output = project(FILES).transform('state.ts')!.code;
116
+
117
+ expect(output).toMatch(/export let store = +new ReactiveObject_\w+\(\[1\]\);/);
118
+ });
119
+
120
+ it('fails the build on every use of reactive that cannot be compiled, with its location', () => {
121
+ let files = {
122
+ 'escape.ts': [
123
+ "import { reactive } from '@esportsplus/reactivity';",
124
+ 'declare function take(value: unknown): void;',
125
+ 'take(reactive);',
126
+ 'take(reactive(0));'
127
+ ].join('\n')
128
+ },
129
+ { transform } = project(files);
130
+
131
+ expect(() => transform('escape.ts')).toThrow(/escape\.ts:3:6[\s\S]*escape\.ts:4:6/);
132
+ });
133
+
134
+ it('fails the build when a signal is destructured out of a module namespace', () => {
135
+ let files = {
136
+ 'state.ts': "import { reactive } from '@esportsplus/reactivity';\nexport let count = reactive(0);",
137
+ 'copy.ts': "import * as S from './state';\nconst { count } = S;\nexport let n = count;"
138
+ },
139
+ { transform } = project(files);
140
+
141
+ expect(() => transform('copy.ts')).toThrow(/copy\.ts:2:9.*destructured/);
142
+ });
143
+
144
+ it('reports the modules a compile relied on so hosts can recompile it when they change', () => {
145
+ let { directory, plugin, transform } = project(FILES),
146
+ dependent = { id: directory + '/reads.ts' };
147
+
148
+ transform('reads.ts');
149
+
150
+ let result = plugin.handleHotUpdate({
151
+ file: directory + '/state.ts',
152
+ modules: [],
153
+ server: {
154
+ moduleGraph: {
155
+ getModulesByFile: (file: string) => file.toLowerCase() === (directory + '/reads.ts').toLowerCase() ? new Set([dependent]) : undefined,
156
+ invalidateModule: () => {}
157
+ }
158
+ },
159
+ timestamp: 1
160
+ });
161
+
162
+ expect(result).toEqual([dependent]);
163
+ });
164
+ });