@esportsplus/reactivity 0.40.0 → 0.41.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.
Files changed (44) hide show
  1. package/README.md +9 -5
  2. package/bench/compiler.bench.ts +115 -0
  3. package/bench/kairo.bench.ts +1 -1
  4. package/bench/reactive/array.bench.ts +1 -0
  5. package/bench/reactive/reactive-object.bench.ts +3 -2
  6. package/build/compiler/array.js +28 -25
  7. package/build/compiler/bindings.d.ts +3 -1
  8. package/build/compiler/bindings.js +32 -9
  9. package/build/compiler/index.d.ts +3 -2
  10. package/build/compiler/index.js +21 -13
  11. package/build/compiler/object.js +12 -13
  12. package/build/compiler/plugins/vite.d.ts +13 -4
  13. package/build/compiler/primitives.js +9 -9
  14. package/build/reactive/index.d.ts +1 -1
  15. package/build/reactive/object.d.ts +3 -3
  16. package/build/reactive/object.js +1 -1
  17. package/oxlintrc.json +3 -0
  18. package/package.json +15 -7
  19. package/pnpm-workspace.yaml +4 -7
  20. package/src/compiler/array.ts +30 -27
  21. package/src/compiler/bindings.ts +49 -10
  22. package/src/compiler/index.ts +37 -20
  23. package/src/compiler/object.ts +14 -16
  24. package/src/compiler/primitives.ts +13 -10
  25. package/src/reactive/array.ts +5 -3
  26. package/src/reactive/index.ts +3 -3
  27. package/src/reactive/object.ts +4 -4
  28. package/src/system.ts +2 -0
  29. package/test/compiler/__snapshots__/output.test.ts.snap +157 -0
  30. package/test/compiler/compiler.test.ts +51 -9
  31. package/test/compiler/composition.test.ts +110 -0
  32. package/test/compiler/coverage.test.ts +22 -22
  33. package/test/compiler/origins.test.ts +156 -0
  34. package/test/compiler/output.test.ts +116 -0
  35. package/test/ownership.test.ts +1 -1
  36. package/test/reactive/array.test.ts +8 -7
  37. package/test/reactive/reactive.test.ts +4 -4
  38. package/test/system.test.ts +3 -4
  39. package/test/untrack.test.ts +1 -1
  40. package/tsconfig.build.json +3 -0
  41. package/tsconfig.json +2 -2
  42. package/vitest.config.ts +2 -2
  43. package/bench/tsconfig.json +0 -16
  44. package/test/tsconfig.json +0 -16
package/src/system.ts CHANGED
@@ -21,6 +21,7 @@ let asyncFactory = new WeakMap<Computed<unknown>, Computed<unknown>>(),
21
21
  depth = 0,
22
22
  disposeHead: Walk | null = null,
23
23
  draining = false,
24
+ // oxlint-disable-next-line unicorn/no-new-array -- preallocated heap; slots are filled by height
24
25
  heap: (Computed<unknown> | undefined)[] = new Array(64),
25
26
  heap_i = 0,
26
27
  heap_n = 0,
@@ -276,6 +277,7 @@ function link<T>(dep: Signal<T> | Computed<T>, sub: Computed<T>) {
276
277
  pooled.nextSub = null,
277
278
  pooled.version = version,
278
279
  pooled)
280
+ // oxlint-disable-next-line sort-keys -- creation order fixes the hidden class every link shares
279
281
  : {
280
282
  dep,
281
283
  sub,
@@ -0,0 +1,157 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`compiled output > app.ts 1`] = `null`;
4
+
5
+ exports[`compiled output > consumer.ts 1`] = `
6
+ "import * as reactivity_u0 from '@esportsplus/reactivity';
7
+ import { count as renamed, later, make, total } from './app';
8
+ import * as app from './app';
9
+ export let a = reactivity_u0.signal(0);
10
+ export let b = () => reactivity_u0.read(renamed) + reactivity_u0.read(total) + reactivity_u0.read(later);
11
+ export let c = () => reactivity_u0.read(app.count) + app.list.$length;
12
+ export let d = () => reactivity_u0.read(app['count']);
13
+ export let e = () => { app.list.$set(0, 1); };"
14
+ `;
15
+
16
+ exports[`compiled output > forms.ts 1`] = `
17
+ "import * as reactivity_u0 from '@esportsplus/reactivity';
18
+
19
+ class ReactiveObject_1x8s4en0<T0 extends unknown[], T1 extends reactivity_u0.Computed<ReturnType<T1>>['fn'], T2 extends unknown[]> extends reactivity_u0.ReactiveObject<any> {
20
+ #a = this[reactivity_u0.SIGNAL](1);
21
+ #b = this[reactivity_u0.SIGNAL]('x');
22
+ #items;
23
+ #sum;
24
+ #neg = this[reactivity_u0.SIGNAL](-1);
25
+ #source;
26
+ #flag = this[reactivity_u0.SIGNAL](true);
27
+
28
+ constructor(_p0: T0, _p1: T1, _p2: T2) {
29
+ super(null);
30
+ this.#items = this[reactivity_u0.REACTIVE_ARRAY](_p0);
31
+ this.#sum = this[reactivity_u0.COMPUTED](_p1);
32
+ this.#source = this[reactivity_u0.REACTIVE_ARRAY](_p2);
33
+ }
34
+
35
+
36
+ get a() {
37
+ return reactivity_u0.read(this.#a);
38
+ }
39
+ set a(_v0) {
40
+ reactivity_u0.write(this.#a, _v0);
41
+ }
42
+
43
+
44
+ get b() {
45
+ return reactivity_u0.read(this.#b);
46
+ }
47
+ set b(_v1) {
48
+ reactivity_u0.write(this.#b, _v1);
49
+ }
50
+
51
+
52
+ get items() {
53
+ return this.#items;
54
+ }
55
+
56
+
57
+ get sum() {
58
+ return reactivity_u0.read(this.#sum);
59
+ }
60
+
61
+
62
+ get neg() {
63
+ return reactivity_u0.read(this.#neg);
64
+ }
65
+ set neg(_v2) {
66
+ reactivity_u0.write(this.#neg, _v2);
67
+ }
68
+
69
+
70
+ get source() {
71
+ return this.#source;
72
+ }
73
+
74
+
75
+ get flag() {
76
+ return reactivity_u0.read(this.#flag);
77
+ }
78
+ set flag(_v3) {
79
+ reactivity_u0.write(this.#flag, _v3);
80
+ }
81
+
82
+ }
83
+
84
+
85
+ class ReactiveObject_1x8s4en1<T extends { 'a'?: unknown, 'items'?: unknown[] }> extends reactivity_u0.ReactiveObject<any> {
86
+ #a;
87
+ #items;
88
+
89
+ constructor(_p0: T['a'], _p1: T['items']) {
90
+ super(null);
91
+ this.#a = this[reactivity_u0.SIGNAL](_p0);
92
+ this.#items = this[reactivity_u0.REACTIVE_ARRAY](_p1);
93
+ }
94
+
95
+
96
+ get a() {
97
+ return reactivity_u0.read(this.#a) as T['a'];
98
+ }
99
+ set a(_v0) {
100
+ reactivity_u0.write(this.#a, _v0);
101
+ }
102
+
103
+
104
+ get items() {
105
+ return this.#items;
106
+ }
107
+
108
+ }
109
+
110
+
111
+ declare function total(): number;
112
+ declare let list: number[];
113
+ declare let n: number;
114
+ let count = reactivity_u0.signal(0);
115
+ let doubled = reactivity_u0.computed(() => reactivity_u0.read(count) * 2);
116
+ let typed = reactivity_u0.signal(n);
117
+ let fn = reactivity_u0.computed(total);
118
+ let runtime = reactivity_u0.reactive(list);
119
+ let obj = new ReactiveObject_1x8s4en0([1, 2], () => 3, list);
120
+ let shorthand = reactivity_u0.reactive({ list });
121
+ let hinted = new ReactiveObject_1x8s4en1<{ a: number; items: number[] }>(1, []);
122
+ let spread = reactivity_u0.reactive({ ...{ a: 1 }, b: 2 });
123
+ let method = reactivity_u0.reactive({ m() { return 1; } });
124
+ let arr = new reactivity_u0.ReactiveArray([1, 2, 3]);
125
+ let empty = new reactivity_u0.ReactiveArray<string>();
126
+ let declared: number[] = new reactivity_u0.ReactiveArray<number>([4]);
127
+ reactivity_u0.write(count, 5);
128
+ reactivity_u0.write(count, count.value + 2);
129
+ reactivity_u0.write(count, count.value || 3);
130
+ reactivity_u0.write(count, count.value + 1);
131
+ reactivity_u0.write(count, count.value - 1);
132
+ let p = ((_t0) => (reactivity_u0.write(count, _t0 + 1), _t0))(count.value);
133
+ let q = (reactivity_u0.write(count, count.value + 1), count.value);
134
+ let o = { count: reactivity_u0.read(count) };
135
+ let t = typeof reactivity_u0.read(count);
136
+ arr.$set(0, 4);
137
+ ((_a, _k) => _a.$set(_k, _a[_k] + (2)))(arr, 1);
138
+ ((_a, _k, _v) => (_a.$set(_k, _v + 1), _v))(arr, 2, arr[2]);
139
+ let r = ((_a, _k) => (_a.$set(_k, _a[_k] + 1), _a[_k]))(arr, 0);
140
+ let size = arr.$length;
141
+ arr.$length = 1;
142
+ arr.$length = arr.length + 1;
143
+ arr.$length = arr.length + 1;
144
+ arr.splice(0, arr.length, ...[5, 6]);
145
+ arr.splice(0, arr.length);
146
+ export { count, doubled, typed, fn, runtime, obj, shorthand, hinted, spread, method, empty, declared, p, q, o, t, r, size };
147
+ export default reactivity_u0.signal(0);"
148
+ `;
149
+
150
+ exports[`compiled output > state.ts 1`] = `
151
+ "import * as reactivity_u0 from '@esportsplus/reactivity';
152
+ export let count = reactivity_u0.signal(0);
153
+ export let list = new reactivity_u0.ReactiveArray([1, 2]);
154
+ export let later: number;
155
+ later = reactivity_u0.signal(1);
156
+ export default reactivity_u0.computed(() => 2);"
157
+ `;
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { ts } from '@esportsplus/typescript';
3
- import { languageService } from '@esportsplus/typescript/compiler';
3
+ import { coordinator, languageService } from '@esportsplus/typescript/compiler';
4
4
  import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
5
- import { NAMESPACE, TYPES } from '~/compiler/constants';
5
+ import { NAMESPACE, PACKAGE_NAME, TYPES } from '~/compiler/constants';
6
6
  import type { Bindings } from '~/compiler/types';
7
7
  import array from '~/compiler/array';
8
8
  import scope from '~/compiler/bindings';
@@ -21,7 +21,7 @@ function applyIntents(code: string, sourceFile: ts.SourceFile, intents: Replacem
21
21
  end = intent.node.getEnd(),
22
22
  start = intent.node.getStart(sourceFile);
23
23
 
24
- code = code.slice(0, start) + intent.generate(sourceFile) + code.slice(end);
24
+ code = code.slice(0, start) + intent.generate(sourceFile, node => node.getText(sourceFile)) + code.slice(end);
25
25
  }
26
26
 
27
27
  return code;
@@ -40,16 +40,15 @@ function parse(code: string): { bindings: Bindings; code: string; isReactiveCall
40
40
  return { bindings, code, isReactiveCall: (node): node is ts.CallExpression => scope.isReactiveCall(bindings, node), sourceFile };
41
41
  }
42
42
 
43
- // Full pipeline over a module importing reactive(): what a real consumer compiles
43
+ // Full pipeline over a module importing reactive(), composed by the coordinator: what a real consumer compiles
44
44
  function transformModule(code: string): string {
45
45
  return transformSource(`import { reactive } from '@esportsplus/reactivity';\n` + code);
46
46
  }
47
47
 
48
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);
49
+ let { checker, program, sourceFile } = languageService.scratch(process.cwd() + '/module.ts', code);
51
50
 
52
- return applyIntents(code, sourceFile, result.replacements ?? []);
51
+ return coordinator.transform([pipeline], code, sourceFile, { checker, program }, process.cwd(), new Map()).code;
53
52
  }
54
53
 
55
54
  function transformPrimitives(source: string): { bindings: Bindings; failures: string[]; output: string } {
@@ -509,15 +508,58 @@ describe('binding resolution (full pipeline)', () => {
509
508
 
510
509
 
511
510
  describe('index transform', () => {
512
- // A module can reach reactive() or read a binding under any name, so no text prefilter may skip it
511
+ // A module can reach reactive() or read a binding under any name, so no name prefilter may skip it
513
512
  it('has no text patterns', () => {
514
513
  expect('patterns' in pipeline).toBe(false);
515
514
  });
516
515
 
516
+ it('names itself in coordinator errors', () => {
517
+ expect(pipeline.name).toBe(PACKAGE_NAME);
518
+ });
519
+
517
520
  it('has transform function', () => {
518
521
  expect(typeof pipeline.transform).toBe('function');
519
522
  });
520
- });
523
+
524
+ it('skips a module without an import before resolving anything', () => {
525
+ let code = 'let reactive = (n: number) => n;\nexport let x = reactive(1);',
526
+ { program, sourceFile } = languageService.scratch(process.cwd() + '/plain.ts', code),
527
+ checker = new Proxy({}, {
528
+ get: () => {
529
+ throw new Error('checker queried');
530
+ }
531
+ }) as ts.Checker;
532
+
533
+ expect(pipeline.transform({ checker, code, program, shared: new Map(), sourceFile })).toEqual({});
534
+ });
535
+
536
+ it('classifies every reactive() argument from one batched type request', () => {
537
+ let code = "import { reactive } from '@esportsplus/reactivity';\ndeclare let n: number;\ndeclare function total(): number;\nlet a = reactive(n);\nlet b = reactive(total);\nlet c = a + b + a + b;",
538
+ { checker, program, sourceFile } = languageService.scratch(process.cwd() + '/batched.ts', code),
539
+ single = 0,
540
+ counted = new Proxy(checker, {
541
+ get(target, key) {
542
+ let value = Reflect.get(target, key);
543
+
544
+ if (key !== 'getTypeAtLocation' || typeof value !== 'function') {
545
+ return value;
546
+ }
547
+
548
+ return (node: unknown) => {
549
+ if (!Array.isArray(node)) {
550
+ single++;
551
+ }
552
+
553
+ return value.call(target, node);
554
+ };
555
+ }
556
+ }),
557
+ output = coordinator.transform([pipeline], code, sourceFile, { checker: counted, program }, process.cwd(), new Map()).code;
558
+
559
+ expect(output).toContain(`let a = ${NAMESPACE}.signal(n);`);
560
+ expect(output).toContain(`let b = ${NAMESPACE}.computed(total);`);
561
+ expect(single).toBe(0);
562
+ });});
521
563
 
522
564
 
523
565
  describe('plugins', () => {
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ts } from '@esportsplus/typescript';
3
+ import { coordinator, languageService } from '@esportsplus/typescript/compiler';
4
+ import type { Plugin, ReplacementIntent } from '@esportsplus/typescript/compiler';
5
+ import { NAMESPACE } from '~/compiler/constants';
6
+
7
+ import pipeline from '~/compiler/index';
8
+
9
+
10
+ // Stands in for template: rewrites every `tag` tagged template, which reactivity's own rewrites may contain
11
+ const FOREIGN: Plugin = {
12
+ name: 'foreign',
13
+ transform: ({ sourceFile }) => {
14
+ let replacements: ReplacementIntent[] = [],
15
+ visit = (node: ts.Node): void => {
16
+ if (ts.isTaggedTemplateExpression(node) && ts.isIdentifier(node.tag) && node.tag.text === 'tag') {
17
+ replacements.push({ generate: () => 'TAGGED', node });
18
+ }
19
+
20
+ node.forEachChild(visit);
21
+ };
22
+
23
+ visit(sourceFile);
24
+
25
+ return { replacements };
26
+ }
27
+ };
28
+
29
+ const PRELUDE = "import { reactive } from '@esportsplus/reactivity';\n";
30
+
31
+
32
+ function compile(code: string, plugins: Plugin[] = [pipeline]): string {
33
+ code = PRELUDE + code;
34
+
35
+ let { checker, program, sourceFile } = languageService.scratch(process.cwd() + '/composed.ts', code);
36
+
37
+ return coordinator.transform(plugins, code, sourceFile, { checker, program }, process.cwd(), new Map()).code;
38
+ }
39
+
40
+
41
+ describe('nested rewrites', () => {
42
+ let output = compile([
43
+ 'let x = reactive(0);',
44
+ 'let y = reactive(1);',
45
+ 'x = y;',
46
+ 'x += y;',
47
+ 'let arr = reactive([y]);',
48
+ 'arr[0] = y;',
49
+ 'arr[1] += y;',
50
+ 'arr[y] = 2;',
51
+ 'let at = arr[y];',
52
+ 'arr.length = y;',
53
+ 'arr = [y];',
54
+ 'let o = reactive({ a: y, items: [y] });',
55
+ 'let s = reactive({ ...{ b: 1 }, c: y });',
56
+ 'let e = { y: { y } };'
57
+ ].join('\n'));
58
+
59
+ it('reads a signal on the right of a write to another', () => {
60
+ expect(output).toContain(`${NAMESPACE}.write(x, ${NAMESPACE}.read(y));`);
61
+ expect(output).toContain(`${NAMESPACE}.write(x, x.value + ${NAMESPACE}.read(y));`);
62
+ });
63
+
64
+ it('reads a signal inside array lowerings', () => {
65
+ expect(output).toContain(`let arr = new ${NAMESPACE}.ReactiveArray([${NAMESPACE}.read(y)]);`);
66
+ expect(output).toContain(`arr.$set(0, ${NAMESPACE}.read(y));`);
67
+ expect(output).toContain(`((_a, _k) => _a.$set(_k, _a[_k] + (${NAMESPACE}.read(y))))(arr, 1);`);
68
+ expect(output).toContain(`arr.$set(${NAMESPACE}.read(y), 2);`);
69
+ expect(output).toContain(`let at = arr[${NAMESPACE}.read(y)];`);
70
+ expect(output).toContain(`arr.$length = ${NAMESPACE}.read(y);`);
71
+ expect(output).toContain(`arr.splice(0, arr.length, ...[${NAMESPACE}.read(y)]);`);
72
+ });
73
+
74
+ it('reads a signal inside object lowerings', () => {
75
+ expect(output).toMatch(new RegExp(`let o = +new ReactiveObject_\\w+\\(${NAMESPACE}\\.read\\(y\\), \\[${NAMESPACE}\\.read\\(y\\)\\]\\);`));
76
+ expect(output).toContain(`let s = ${NAMESPACE}.reactive({ ...{ b: 1 }, c: ${NAMESPACE}.read(y) });`);
77
+ expect(output).toContain(`let e = { y: { y: ${NAMESPACE}.read(y) } };`);
78
+ });
79
+ });
80
+
81
+
82
+ describe('composition with another plugin', () => {
83
+ let code = [
84
+ 'declare function tag(strings: TemplateStringsArray): number;',
85
+ 'let count = reactive(tag`a`);',
86
+ 'count = tag`b`;',
87
+ 'count += tag`c`;',
88
+ 'let o = reactive({ n: tag`d` });',
89
+ 'let s = reactive({ ...{}, n: tag`e` });',
90
+ 'let arr = reactive([tag`f`]);',
91
+ 'arr[0] = tag`g`;',
92
+ 'arr = [tag`h`];'
93
+ ].join('\n');
94
+
95
+ for (let [order, plugins] of [['before', [FOREIGN, pipeline]], ['after', [pipeline, FOREIGN]]] as const) {
96
+ it(`keeps the other plugin's replacements inside every lowering when it runs ${order}`, () => {
97
+ let output = compile(code, [...plugins]);
98
+
99
+ expect(output).not.toContain('tag`');
100
+ expect(output).toContain(`let count = ${NAMESPACE}.signal(TAGGED);`);
101
+ expect(output).toContain(`${NAMESPACE}.write(count, TAGGED);`);
102
+ expect(output).toContain(`${NAMESPACE}.write(count, count.value + TAGGED);`);
103
+ expect(output).toMatch(/let o = +new ReactiveObject_\w+\(TAGGED\);/);
104
+ expect(output).toContain(`let s = ${NAMESPACE}.reactive({ ...{}, n: TAGGED });`);
105
+ expect(output).toContain(`let arr = new ${NAMESPACE}.ReactiveArray([TAGGED]);`);
106
+ expect(output).toContain('arr.$set(0, TAGGED);');
107
+ expect(output).toContain('arr.splice(0, arr.length, ...[TAGGED]);');
108
+ });
109
+ }
110
+ });
@@ -7,18 +7,24 @@ import vite from '~/compiler/plugins/vite';
7
7
 
8
8
  // Every route by which a module reaches `reactive`, or a binding it created, must compile
9
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
10
  'barrel.ts': [
18
11
  "export { reactive as make } from '@esportsplus/reactivity';",
19
12
  "export * as R from '@esportsplus/reactivity';",
20
13
  "export * from './state';"
21
14
  ].join('\n'),
15
+ 'entries.ts': [
16
+ "import { make, R } from './barrel';",
17
+ "import * as B from './barrel';",
18
+ 'export let a = make(0);',
19
+ 'export let b = R.reactive(0);',
20
+ 'export let c = B.R.reactive(0);',
21
+ "export let d = R['reactive'](0);",
22
+ 'const alias = make;',
23
+ 'export let e = alias(0);',
24
+ 'const { reactive: destructured } = R;',
25
+ 'export let f = destructured(0);',
26
+ 'export let g = () => a + b + c + d + e + f;'
27
+ ].join('\n'),
22
28
  'reads.ts': [
23
29
  "import { count, list, store } from './state';",
24
30
  "import * as S from './state';",
@@ -33,18 +39,12 @@ const FILES: Record<string, string> = {
33
39
  'export let g = () => store.items.length;',
34
40
  "export let h = () => S['count'] + 1;"
35
41
  ].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;'
42
+ 'state.ts': [
43
+ "import { reactive } from '@esportsplus/reactivity';",
44
+ 'export let count = reactive(0);',
45
+ 'export let list = reactive([1, 2]);',
46
+ 'export let store = reactive({ n: 1, items: [1] });',
47
+ 'export default reactive(0);'
48
48
  ].join('\n')
49
49
  };
50
50
 
@@ -73,7 +73,7 @@ function project(files: Record<string, string>) {
73
73
  return {
74
74
  directory,
75
75
  plugin,
76
- transform: (name: string) => plugin.transform(files[name], directory + '/' + name)
76
+ transform: (name: string) => plugin.transform.handler(files[name], directory + '/' + name)
77
77
  };
78
78
  }
79
79
 
@@ -133,8 +133,8 @@ describe('compiler coverage', () => {
133
133
 
134
134
  it('fails the build when a signal is destructured out of a module namespace', () => {
135
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;"
136
+ 'copy.ts': "import * as S from './state';\nconst { count } = S;\nexport let n = count;",
137
+ 'state.ts': "import { reactive } from '@esportsplus/reactivity';\nexport let count = reactive(0);"
138
138
  },
139
139
  { transform } = project(files);
140
140
 
@@ -0,0 +1,156 @@
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
+
6
+ import vite from '~/compiler/plugins/vite';
7
+
8
+
9
+ // `reactive`, and the bindings it creates, reached under any name: resolution may prune what it
10
+ // sends to the checker, never by name
11
+ const FILES: Record<string, string> = {
12
+ 'app.ts': [
13
+ "export { reactive as signalOf } from '@esportsplus/reactivity';",
14
+ "export { default as fallback } from './state';",
15
+ "export * from './mid';"
16
+ ].join('\n'),
17
+ 'consumer.ts': [
18
+ "import { deepCount, fallback, held, later, signalOf } from './app';",
19
+ "import * as app from './app';",
20
+ 'const { signalOf: unpacked } = app;',
21
+ 'export let a = signalOf(0);',
22
+ 'export let b = app.signalOf(0);',
23
+ "export let c = app['signalOf'](0);",
24
+ 'export let d = unpacked(0);',
25
+ 'export let e = held(0);',
26
+ 'export let f = () => fallback + deepCount + later;',
27
+ 'export let g = () => app.later + app.deepCount;'
28
+ ].join('\n'),
29
+ 'deep.ts': [
30
+ "export * from '@esportsplus/reactivity';",
31
+ "export { count as deepCount } from './state';"
32
+ ].join('\n'),
33
+ 'held.ts': [
34
+ "import { reactive } from '@esportsplus/reactivity';",
35
+ 'export const held = reactive;'
36
+ ].join('\n'),
37
+ 'local.ts': [
38
+ "import { reactive as x } from '@esportsplus/reactivity';",
39
+ 'export function before() { return late; }',
40
+ 'let late: number;',
41
+ 'late = x(0);',
42
+ 'export let after = late + 1;',
43
+ 'export let count = x(1);',
44
+ 'export let next = count + 1;',
45
+ 'export function shadow(x: (n: number) => number) { return x(1); }'
46
+ ].join('\n'),
47
+ 'mid.ts': [
48
+ "export * from './deep';",
49
+ "export { held } from './held';",
50
+ "export { later } from './state';"
51
+ ].join('\n'),
52
+ 'star.ts': [
53
+ "import { reactive } from './mid';",
54
+ 'export let s = reactive(0);',
55
+ 'export let t = s + 1;'
56
+ ].join('\n'),
57
+ 'state.ts': [
58
+ "import { reactive } from '@esportsplus/reactivity';",
59
+ 'export let count = reactive(0);',
60
+ 'export let later: number;',
61
+ 'later = reactive(1);',
62
+ 'export default reactive(2);'
63
+ ].join('\n')
64
+ };
65
+
66
+
67
+ let fixtures: string[] = [];
68
+
69
+
70
+ function compile(name: string, files: Record<string, string> = FILES): string {
71
+ let directory = mkdtempSync(join(process.cwd(), '.fixture-origins-')).replace(/\\/g, '/');
72
+
73
+ fixtures.push(directory);
74
+
75
+ for (let [file, code] of Object.entries(files)) {
76
+ writeFileSync(join(directory, file), code);
77
+ }
78
+
79
+ writeFileSync(join(directory, 'tsconfig.json'), JSON.stringify({
80
+ compilerOptions: { module: 'esnext', moduleResolution: 'bundler', strict: true, target: 'esnext', types: [] },
81
+ files: Object.keys(files).map(file => './' + file)
82
+ }));
83
+
84
+ let plugin = vite({ root: directory });
85
+
86
+ plugin.configResolved({ command: 'build', root: directory });
87
+
88
+ return plugin.transform.handler(files[name], directory + '/' + name)?.code ?? files[name];
89
+ }
90
+
91
+
92
+ afterEach(() => {
93
+ for (let i = 0, n = fixtures.length; i < n; i++) {
94
+ rmSync(fixtures[i], { force: true, recursive: true });
95
+ }
96
+
97
+ fixtures = [];
98
+ });
99
+
100
+
101
+ describe('origins reached under any name', () => {
102
+ it('compiles reactive() imported under a local alias, and leaves a parameter shadowing it', () => {
103
+ let output = compile('local.ts');
104
+
105
+ expect(output).toContain(`export let count = ${NAMESPACE}.signal(1);`);
106
+ expect(output).toContain(`export let next = ${NAMESPACE}.read(count) + 1;`);
107
+ expect(output).toContain('export function shadow(x: (n: number) => number) { return x(1); }');
108
+ });
109
+
110
+ it('reads a let assigned with reactive() later, before and after the assignment', () => {
111
+ let output = compile('local.ts');
112
+
113
+ expect(output).toContain(`export function before() { return ${NAMESPACE}.read(late); }`);
114
+ expect(output).toContain(`late = ${NAMESPACE}.signal(0);`);
115
+ expect(output).toContain(`export let after = ${NAMESPACE}.read(late) + 1;`);
116
+ });
117
+
118
+ it('reads a let assigned with reactive() in another module', () => {
119
+ let output = compile('consumer.ts');
120
+
121
+ expect(output).toContain(`${NAMESPACE}.read(later)`);
122
+ expect(output).toContain(`${NAMESPACE}.read(app.later)`);
123
+ });
124
+
125
+ it('compiles reactive() re-exported renamed through a central module, by name and through its namespace', () => {
126
+ let output = compile('consumer.ts');
127
+
128
+ for (let name of ['a', 'b', 'c', 'd']) {
129
+ expect(output).toContain(`export let ${name} = ${NAMESPACE}.signal(0);`);
130
+ }
131
+ });
132
+
133
+ it('compiles reactive() held by an exported const', () => {
134
+ expect(compile('consumer.ts')).toContain(`export let e = ${NAMESPACE}.signal(0);`);
135
+ });
136
+
137
+ it('reads a default export re-exported renamed, and bindings through export * two deep', () => {
138
+ let output = compile('consumer.ts');
139
+
140
+ expect(output).toContain(`export let f = () => ${NAMESPACE}.read(fallback) + ${NAMESPACE}.read(deepCount) + ${NAMESPACE}.read(later);`);
141
+ expect(output).toContain(`export let g = () => ${NAMESPACE}.read(app.later) + ${NAMESPACE}.read(app.deepCount);`);
142
+ });
143
+
144
+ it('compiles reactive() reached through export * two deep', () => {
145
+ let output = compile('star.ts');
146
+
147
+ expect(output).toContain(`export let s = ${NAMESPACE}.signal(0);`);
148
+ expect(output).toContain(`export let t = ${NAMESPACE}.read(s) + 1;`);
149
+ });
150
+
151
+ it('leaves a module that imports nothing untouched', () => {
152
+ let code = 'let reactive = (n: number) => n;\nexport let x = reactive(1);';
153
+
154
+ expect(compile('plain.ts', { ...FILES, 'plain.ts': code })).toBe(code);
155
+ });
156
+ });