@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.
- package/README.md +12 -6
- package/bench/compiler.bench.ts +115 -0
- package/bench/kairo.bench.ts +1 -1
- package/bench/reactive/array.bench.ts +1 -0
- package/bench/reactive/reactive-object.bench.ts +3 -2
- package/build/compiler/array.js +28 -25
- package/build/compiler/bindings.d.ts +3 -1
- package/build/compiler/bindings.js +32 -9
- package/build/compiler/index.d.ts +3 -2
- package/build/compiler/index.js +21 -13
- package/build/compiler/object.js +12 -13
- package/build/compiler/plugins/vite.d.ts +13 -4
- package/build/compiler/primitives.js +9 -9
- package/build/constants.d.ts +2 -1
- package/build/constants.js +2 -1
- package/build/index.d.ts +1 -0
- package/build/reactive/array.d.ts +40 -1
- package/build/reactive/array.js +82 -16
- package/build/reactive/index.d.ts +1 -1
- package/build/reactive/object.d.ts +3 -3
- package/build/reactive/object.js +1 -1
- package/build/system.d.ts +3 -2
- package/build/system.js +10 -5
- package/oxlintrc.json +3 -0
- package/package.json +15 -7
- package/pnpm-workspace.yaml +4 -7
- package/src/compiler/array.ts +30 -27
- package/src/compiler/bindings.ts +49 -10
- package/src/compiler/index.ts +37 -20
- package/src/compiler/object.ts +14 -16
- package/src/compiler/primitives.ts +13 -10
- package/src/constants.ts +4 -1
- package/src/index.ts +1 -0
- package/src/reactive/array.ts +133 -22
- package/src/reactive/index.ts +3 -3
- package/src/reactive/object.ts +4 -4
- package/src/system.ts +20 -5
- package/test/compiler/__snapshots__/output.test.ts.snap +157 -0
- package/test/compiler/compiler.test.ts +51 -9
- package/test/compiler/composition.test.ts +110 -0
- package/test/compiler/coverage.test.ts +22 -22
- package/test/compiler/origins.test.ts +156 -0
- package/test/compiler/output.test.ts +116 -0
- package/test/ownership.test.ts +100 -1
- package/test/reactive/array.test.ts +189 -8
- package/test/reactive/reactive.test.ts +4 -4
- package/test/system.test.ts +3 -4
- package/test/untrack.test.ts +1 -1
- package/tsconfig.build.json +3 -0
- package/tsconfig.json +2 -2
- package/vitest.config.ts +2 -2
- package/bench/tsconfig.json +0 -16
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
'
|
|
37
|
-
"import {
|
|
38
|
-
|
|
39
|
-
'export let
|
|
40
|
-
'export let
|
|
41
|
-
'export
|
|
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
|
-
'
|
|
137
|
-
'
|
|
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
|
+
});
|