@esportsplus/reactivity 0.39.2 → 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 (53) hide show
  1. package/README.md +12 -6
  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/constants.d.ts +2 -1
  15. package/build/constants.js +2 -1
  16. package/build/index.d.ts +1 -0
  17. package/build/reactive/array.d.ts +40 -1
  18. package/build/reactive/array.js +82 -16
  19. package/build/reactive/index.d.ts +1 -1
  20. package/build/reactive/object.d.ts +3 -3
  21. package/build/reactive/object.js +1 -1
  22. package/build/system.d.ts +3 -2
  23. package/build/system.js +10 -5
  24. package/oxlintrc.json +3 -0
  25. package/package.json +15 -7
  26. package/pnpm-workspace.yaml +4 -7
  27. package/src/compiler/array.ts +30 -27
  28. package/src/compiler/bindings.ts +49 -10
  29. package/src/compiler/index.ts +37 -20
  30. package/src/compiler/object.ts +14 -16
  31. package/src/compiler/primitives.ts +13 -10
  32. package/src/constants.ts +4 -1
  33. package/src/index.ts +1 -0
  34. package/src/reactive/array.ts +133 -22
  35. package/src/reactive/index.ts +3 -3
  36. package/src/reactive/object.ts +4 -4
  37. package/src/system.ts +20 -5
  38. package/test/compiler/__snapshots__/output.test.ts.snap +157 -0
  39. package/test/compiler/compiler.test.ts +51 -9
  40. package/test/compiler/composition.test.ts +110 -0
  41. package/test/compiler/coverage.test.ts +22 -22
  42. package/test/compiler/origins.test.ts +156 -0
  43. package/test/compiler/output.test.ts +116 -0
  44. package/test/ownership.test.ts +100 -1
  45. package/test/reactive/array.test.ts +189 -8
  46. package/test/reactive/reactive.test.ts +4 -4
  47. package/test/system.test.ts +3 -4
  48. package/test/untrack.test.ts +1 -1
  49. package/tsconfig.build.json +3 -0
  50. package/tsconfig.json +2 -2
  51. package/vitest.config.ts +2 -2
  52. package/bench/tsconfig.json +0 -16
  53. package/test/tsconfig.json +0 -16
package/src/system.ts CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  PACKAGE_NAME,
3
3
  SIGNAL,
4
4
  STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED,
5
- STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
5
+ STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_DISPOSED, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
6
6
  } from './constants';
7
7
  import { Computed, ComputedResult, Link, SelectorSignal, Settled, Signal } from './types';
8
8
  import { isObject, isPromise } from '@esportsplus/utilities';
@@ -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,
@@ -941,6 +943,7 @@ const dispose = <T>(computed: Computed<T>): void => {
941
943
  try {
942
944
  for (;;) {
943
945
  deleteFromHeap(node);
946
+ node.state |= STATE_DISPOSED;
944
947
 
945
948
  let dep = node.deps;
946
949
 
@@ -1116,9 +1119,21 @@ const read = <T>(node: Signal<T> | Computed<T>): T => {
1116
1119
  return node.value;
1117
1120
  };
1118
1121
 
1122
+ // The node that owns what is created now: the running computed or effect, else the running root's scope. Walking its
1123
+ // .owner chain finds every enclosing owner.
1124
+ const owner = (): Computed<unknown> | null => observer || scope;
1125
+
1119
1126
  // Owned before fn runs (nothing can register on the owner meanwhile, so ownerSlot is unchanged): a
1120
1127
  // root disposed inside its own fn then unlinks instead of being parked on the owner as a dead child.
1121
- const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1128
+ //
1129
+ // 'parent' replaces whatever is running as the owner, for ownership only: reads in fn stay untracked. With it,
1130
+ // root(() => ...) runs fn owned by 'parent' (re-entering it after an await) and root(dispose => ...) creates a
1131
+ // child root of 'parent'. A disposed node cannot own anything: nothing would dispose what it took.
1132
+ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T), parent?: Computed<unknown> | null) => {
1133
+ if (parent && parent.state & STATE_DISPOSED) {
1134
+ throw new Error(`${PACKAGE_NAME}: root() was given a disposed owner`);
1135
+ }
1136
+
1122
1137
  let o = observer,
1123
1138
  s = scope,
1124
1139
  tracking = fn.length,
@@ -1129,7 +1144,7 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1129
1144
  try {
1130
1145
  if (tracking) {
1131
1146
  let self = makeNode(noop),
1132
- owner = o || s;
1147
+ owner = parent === undefined ? o || s : parent;
1133
1148
 
1134
1149
  if (owner !== null) {
1135
1150
  own(owner, self);
@@ -1139,7 +1154,7 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1139
1154
  value = (fn as (dispose: VoidFunction) => T)(() => dispose(self));
1140
1155
  }
1141
1156
  else {
1142
- scope = null;
1157
+ scope = parent ?? null;
1143
1158
  value = (fn as () => T)();
1144
1159
  }
1145
1160
  }
@@ -1269,7 +1284,7 @@ export {
1269
1284
  flush,
1270
1285
  hasOwner,
1271
1286
  isComputed, isSignal,
1272
- onCleanup,
1287
+ onCleanup, owner,
1273
1288
  peek,
1274
1289
  read, root,
1275
1290
  signal,
@@ -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
+ });