@esportsplus/reactivity 0.37.2 → 0.38.1

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.
@@ -3,7 +3,6 @@ import { code, uid } from '@esportsplus/typescript/compiler';
3
3
  import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
4
4
  import { NAMESPACE, TYPES } from './constants';
5
5
  import type { Bindings, IsReactiveCall } from './types';
6
- import scope from './bindings';
7
6
 
8
7
 
9
8
  interface AnalyzedProperty {
@@ -34,7 +33,9 @@ interface VisitContext {
34
33
  }
35
34
 
36
35
 
37
- function analyzeProperty(prop: ts.ObjectLiteralElementLike, sourceFile: ts.SourceFile): AnalyzedProperty | null {
36
+ function analyzeProperty(prop: ts.ObjectLiteralElementLike, bindings: Bindings): AnalyzedProperty | null {
37
+ let sourceFile = bindings.sourceFile;
38
+
38
39
  if (!ts.isPropertyAssignment(prop)) {
39
40
  return null;
40
41
  }
@@ -88,6 +89,14 @@ function analyzeProperty(prop: ts.ObjectLiteralElementLike, sourceFile: ts.Sourc
88
89
  return { isStatic, key, type: TYPES.Array, valueText };
89
90
  }
90
91
 
92
+ // A non-literal array is typed Reactive<T[]> on the object, so it must become a ReactiveArray
93
+ // exactly like a literal one
94
+ let type = isStaticValue(unwrapped) ? undefined : bindings.checker.getTypeAtLocation(value);
95
+
96
+ if (type && (bindings.checker.isArrayType(type) || bindings.checker.isTupleType(type))) {
97
+ return { isStatic: false, key, type: TYPES.Array, valueText };
98
+ }
99
+
91
100
  return {
92
101
  isStatic: isStaticValue(value),
93
102
  key,
@@ -224,10 +233,9 @@ function visit(ctx: VisitContext, node: ts.Node): void {
224
233
  if (arg && ts.isObjectLiteralExpression(arg)) {
225
234
  let properties: AnalyzedProperty[] = [],
226
235
  props = arg.properties,
227
- target = node.parent && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)
228
- ? node.parent.name
229
- : null,
230
- varname = target?.text ?? null;
236
+ varname = node.parent && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)
237
+ ? node.parent.name.text
238
+ : null;
231
239
 
232
240
  for (let i = 0, n = props.length; i < n; i++) {
233
241
  let prop = props[i];
@@ -237,7 +245,7 @@ function visit(ctx: VisitContext, node: ts.Node): void {
237
245
  return;
238
246
  }
239
247
 
240
- let analyzed = analyzeProperty(prop, ctx.sourceFile);
248
+ let analyzed = analyzeProperty(prop, ctx.bindings);
241
249
 
242
250
  if (!analyzed) {
243
251
  node.forEachChild(n => visit(ctx, n));
@@ -247,15 +255,6 @@ function visit(ctx: VisitContext, node: ts.Node): void {
247
255
  properties.push(analyzed);
248
256
  }
249
257
 
250
- // Declared only once every property analyzed: a bailed object stays a runtime reactive()
251
- if (target) {
252
- for (let i = 0, n = properties.length; i < n; i++) {
253
- if (properties[i].type === TYPES.Array) {
254
- scope.declarePath(ctx.bindings, target, properties[i].key);
255
- }
256
- }
257
- }
258
-
259
258
  ctx.calls.push({
260
259
  classname: uid('ReactiveObject'),
261
260
  node,
@@ -290,8 +289,9 @@ export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: I
290
289
  prepend.push(buildClassCode(call.classname, call.properties, typehint));
291
290
  replacements.push({
292
291
  generate: () => {
292
+ // Every property but a static signal (baked into its field) is a constructor parameter
293
293
  let args = call.properties
294
- .filter(({ isStatic, type }) => typehint || !isStatic || type === TYPES.Computed)
294
+ .filter(({ isStatic, type }) => typehint || !isStatic || type !== TYPES.Signal)
295
295
  .map(p => p.valueText)
296
296
  .join(', ');
297
297
 
@@ -5,68 +5,64 @@ import type { Bindings, IsReactiveCall } from './types';
5
5
  import scope from './bindings';
6
6
 
7
7
 
8
+ // Something the compiler cannot lower; reported (every one of them) as a build error
9
+ type Failure = { message: string; node: ts.Node };
10
+
8
11
  type PrimitivesTransformResult = {
9
12
  calls: ts.CallExpression[];
13
+ failures: Failure[];
10
14
  replacements: ReplacementIntent[];
11
15
  };
12
16
 
13
17
  type TransformContext = {
14
18
  bindings: Bindings;
15
19
  calls: ts.CallExpression[];
20
+ failures: Failure[];
16
21
  isReactiveCall: IsReactiveCall;
17
22
  replacements: ReplacementIntent[];
18
23
  tmpCounter: number;
19
24
  };
20
25
 
21
26
 
22
- // Pass 1: record every reactive() call and the variable each signal/computed is bound to, before
23
- // any reference is rewritten — a use that precedes its declaration in source order still resolves
27
+ // Pass 1: lower every reactive() call. A signal or computed is only ever reached through the
28
+ // variable it initializes (every read of that variable is rewritten), so anywhere else its signal
29
+ // would leak unread and the call is rejected.
24
30
  function declare(ctx: TransformContext, node: ts.Node): void {
25
31
  if (ctx.isReactiveCall(node)) {
26
- let call = node;
32
+ let call = node,
33
+ kind = scope.classify(ctx.bindings, call);
27
34
 
28
35
  ctx.calls.push(call);
29
36
 
30
- if (call.arguments.length > 0) {
31
- let arg = call.arguments[0],
32
- classification: TYPES | null = TYPES.Signal;
33
-
34
- if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) {
35
- classification = TYPES.Computed;
36
- }
37
- else {
38
- let unwrapped = arg;
39
-
40
- while (ts.isAsExpression(unwrapped) || ts.isParenthesizedExpression(unwrapped) || ts.isTypeAssertion(unwrapped)) {
41
- unwrapped = unwrapped.expression;
42
- }
43
-
44
- if (ts.isArrayLiteralExpression(unwrapped) || ts.isObjectLiteralExpression(unwrapped)) {
45
- classification = null;
46
- }
47
- // Dynamic expression - use runtime reactive via namespace
48
- else if (ts.isCallExpression(unwrapped) || ts.isIdentifier(unwrapped)) {
49
- classification = null;
50
- ctx.replacements.push({
51
- generate: () => `${NAMESPACE}.reactive`,
52
- node: call.expression
53
- });
54
- }
37
+ if (kind === null) {
38
+ ctx.failures.push({
39
+ message: 'reactive() takes one argument whose kind (value, computed, array or object) is provable; ' +
40
+ 'pass a literal or give the argument a concrete type (not any, unknown or a mixed union)',
41
+ node: call
42
+ });
43
+ }
44
+ else if (kind === TYPES.Signal || kind === TYPES.Computed) {
45
+ if (!isBound(call)) {
46
+ ctx.failures.push({
47
+ message: `a reactive ${kind === TYPES.Signal ? 'value' : 'computed'} must initialize a variable (or be a module's ` +
48
+ 'default export); anywhere else its signal is never read',
49
+ node: call
50
+ });
55
51
  }
56
52
 
57
- if (classification !== null) {
58
- let target = targetOf(call),
59
- type = classification;
60
-
61
- if (target) {
62
- scope.declare(ctx.bindings, target, type);
63
- }
53
+ ctx.replacements.push({
54
+ generate: () => kind === TYPES.Computed ? `${NAMESPACE}.computed` : `${NAMESPACE}.signal`,
55
+ node: call.expression
56
+ });
57
+ }
58
+ else {
59
+ let arg = scope.unwrap(call.arguments[0]);
64
60
 
65
- // Replace just the 'reactive' identifier with the appropriate namespace function
61
+ // Literals are lowered by the array and object transforms; any other array or object
62
+ // is built by the runtime constructor
63
+ if (!ts.isArrayLiteralExpression(arg) && !ts.isObjectLiteralExpression(arg)) {
66
64
  ctx.replacements.push({
67
- generate: () => type === TYPES.Computed
68
- ? `${NAMESPACE}.computed`
69
- : `${NAMESPACE}.signal`,
65
+ generate: () => `${NAMESPACE}.reactive`,
70
66
  node: call.expression
71
67
  });
72
68
  }
@@ -76,22 +72,51 @@ function declare(ctx: TransformContext, node: ts.Node): void {
76
72
  node.forEachChild(child => declare(ctx, child));
77
73
  }
78
74
 
79
- // A name slot rather than a value reference: a declaration's or member's own name, a property
80
- // name (`o.count`, `{ count: 1 }`, `{ count: local } = o`), or a label. A shorthand property's
81
- // name doubles as a read of the variable, so it is not a name slot. (TS 7's `isDeclarationName`
82
- // only tests the node kind, so it is true for every identifier and cannot be used here.)
83
- function isName(node: ts.Identifier, parent: ts.Node): boolean {
84
- if (ts.isShorthandPropertyAssignment(parent)) {
75
+ // `let x = reactive(...)`, `x = reactive(...)` or `export default reactive(...)`
76
+ function isBound(call: ts.CallExpression): boolean {
77
+ let node: ts.Node = call,
78
+ parent = node.parent;
79
+
80
+ while (
81
+ parent &&
82
+ (ts.isAsExpression(parent) || ts.isNonNullExpression(parent) || ts.isParenthesizedExpression(parent) || ts.isSatisfiesExpression(parent))
83
+ ) {
84
+ node = parent;
85
+ parent = node.parent;
86
+ }
87
+
88
+ if (!parent) {
85
89
  return false;
86
90
  }
87
91
 
88
- let slots = parent as { label?: ts.Node; name?: ts.Node; propertyName?: ts.Node };
92
+ if (ts.isVariableDeclaration(parent)) {
93
+ return parent.initializer === node && ts.isIdentifier(parent.name);
94
+ }
95
+
96
+ if (ts.isBinaryExpression(parent)) {
97
+ return parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === node && ts.isIdentifier(parent.left);
98
+ }
99
+
100
+ return ts.isExportAssignment(parent);
101
+ }
102
+
103
+ // A module namespace object: `import * as ns` (or an alias of it) or an `await import(...)`
104
+ function isNamespace(bindings: Bindings, expression: ts.Expression): boolean {
105
+ let value = scope.unwrap(expression);
106
+
107
+ if (ts.isAwaitExpression(value)) {
108
+ value = scope.unwrap(value.expression);
109
+ }
110
+
111
+ if (ts.isCallExpression(value)) {
112
+ return value.expression.kind === ts.SyntaxKind.ImportKeyword;
113
+ }
89
114
 
90
- return slots.name === node || slots.propertyName === node || slots.label === node;
115
+ return ts.isIdentifier(value) && scope.origin(bindings, value)?.declaration.kind === ts.SyntaxKind.SourceFile;
91
116
  }
92
117
 
93
118
  // Destructuring assignment targets (`({ count } = o)`, `[count] = xs`) write through a pattern
94
- // that has no per-signal rewrite, so they are left untouched
119
+ // that has no per-signal rewrite
95
120
  function isPatternTarget(node: ts.Node): boolean {
96
121
  let current = node;
97
122
 
@@ -119,32 +144,42 @@ function isPatternTarget(node: ts.Node): boolean {
119
144
  ((ts.isForOfStatement(parent) || ts.isForInStatement(parent)) && parent.initializer === current);
120
145
  }
121
146
 
122
- function reference(ctx: TransformContext, node: ts.Identifier): void {
147
+ // A binding read as `ns.count` / `ns['count']` is rewritten as a whole
148
+ function readNode(identifier: ts.Node): ts.Expression {
149
+ let parent = identifier.parent!;
150
+
151
+ if (
152
+ (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
153
+ (ts.isElementAccessExpression(parent) && parent.argumentExpression === identifier)
154
+ ) {
155
+ return parent;
156
+ }
157
+
158
+ return identifier as ts.Expression;
159
+ }
160
+
161
+ function reference(ctx: TransformContext, node: ts.Expression, kind: TYPES): void {
123
162
  let parent = node.parent;
124
163
 
125
- if (!parent || ts.isExportSpecifier(parent) || ts.isImportSpecifier(parent) || isName(node, parent)) {
164
+ if (!parent) {
126
165
  return;
127
166
  }
128
167
 
129
- let binding = scope.resolve(ctx.bindings, node);
130
-
131
- if (!binding || binding.type === TYPES.Array) {
168
+ // `x = reactive(...)` replaces the signal itself
169
+ if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.left === node && ctx.isReactiveCall(scope.unwrap(parent.right))) {
132
170
  return;
133
171
  }
134
172
 
135
- if (
136
- (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.left === node && ctx.isReactiveCall(parent.right)) ||
137
- (ts.isTypeOfExpression(parent) && parent.expression === node) ||
138
- isPatternTarget(node)
139
- ) {
173
+ if (isPatternTarget(node)) {
174
+ ctx.failures.push({ message: 'a reactive binding cannot be assigned through a destructuring pattern; assign it directly', node });
140
175
  return;
141
176
  }
142
177
 
143
- let name = node.text;
178
+ let text = node.getText(ctx.bindings.sourceFile);
144
179
 
145
180
  if (ts.isShorthandPropertyAssignment(parent)) {
146
181
  ctx.replacements.push({
147
- generate: () => `${name}: ${NAMESPACE}.read(${name})`,
182
+ generate: () => `${text}: ${NAMESPACE}.read(${text})`,
148
183
  node: parent
149
184
  });
150
185
 
@@ -173,14 +208,15 @@ function reference(ctx: TransformContext, node: ts.Identifier): void {
173
208
 
174
209
  if (!writeCtx) {
175
210
  ctx.replacements.push({
176
- generate: () => `${NAMESPACE}.read(${name})`,
211
+ generate: () => `${NAMESPACE}.read(${text})`,
177
212
  node
178
213
  });
179
214
 
180
215
  return;
181
216
  }
182
217
 
183
- if (binding.type === TYPES.Computed) {
218
+ if (kind === TYPES.Computed) {
219
+ ctx.failures.push({ message: 'a reactive computed is read-only', node });
184
220
  return;
185
221
  }
186
222
 
@@ -188,7 +224,7 @@ function reference(ctx: TransformContext, node: ts.Identifier): void {
188
224
  let right = parent.right;
189
225
 
190
226
  ctx.replacements.push({
191
- generate: (sf) => `${NAMESPACE}.write(${name}, ${right.getText(sf)})`,
227
+ generate: (sf) => `${NAMESPACE}.write(${text}, ${right.getText(sf)})`,
192
228
  node: parent
193
229
  });
194
230
  }
@@ -197,7 +233,7 @@ function reference(ctx: TransformContext, node: ts.Identifier): void {
197
233
  right = parent.right;
198
234
 
199
235
  ctx.replacements.push({
200
- generate: (sf) => `${NAMESPACE}.write(${name}, ${name}.value ${op} ${right.getText(sf)})`,
236
+ generate: (sf) => `${NAMESPACE}.write(${text}, ${text}.value ${op} ${right.getText(sf)})`,
201
237
  node: parent
202
238
  });
203
239
  }
@@ -207,13 +243,13 @@ function reference(ctx: TransformContext, node: ts.Identifier): void {
207
243
 
208
244
  if (ts.isExpressionStatement(unary.parent)) {
209
245
  ctx.replacements.push({
210
- generate: () => `${NAMESPACE}.write(${name}, ${name}.value ${delta})`,
246
+ generate: () => `${NAMESPACE}.write(${text}, ${text}.value ${delta})`,
211
247
  node: unary
212
248
  });
213
249
  }
214
250
  else if (ts.isPrefixUnaryExpression(unary)) {
215
251
  ctx.replacements.push({
216
- generate: () => `(${NAMESPACE}.write(${name}, ${name}.value ${delta}), ${name}.value)`,
252
+ generate: () => `(${NAMESPACE}.write(${text}, ${text}.value ${delta}), ${text}.value)`,
217
253
  node: unary
218
254
  });
219
255
  }
@@ -221,42 +257,55 @@ function reference(ctx: TransformContext, node: ts.Identifier): void {
221
257
  let tmp = `_t${ctx.tmpCounter++}`;
222
258
 
223
259
  ctx.replacements.push({
224
- generate: () => `((${tmp}) => (${NAMESPACE}.write(${name}, ${tmp} ${delta}), ${tmp}))(${name}.value)`,
260
+ generate: () => `((${tmp}) => (${NAMESPACE}.write(${text}, ${tmp} ${delta}), ${tmp}))(${text}.value)`,
225
261
  node: unary
226
262
  });
227
263
  }
228
264
  }
229
265
  }
230
266
 
231
- // Pass 2: rewrite reads and writes of every signal/computed binding. Type positions never read a
232
- // value, so they are not walked at all.
233
- function rewrite(ctx: TransformContext, node: ts.Node): void {
234
- if (ts.isTypeNode(node)) {
235
- return;
236
- }
267
+ // Pass 2: rewrite every read and write of a signal or computed binding, resolved by declaration so
268
+ // a binding imported from another module (named, default, namespace, through a barrel) compiles
269
+ // exactly like a local one. Destructuring one out of a module namespace would copy its signal.
270
+ function rewrite(ctx: TransformContext): void {
271
+ let { bindings } = ctx;
237
272
 
238
- if (ts.isIdentifier(node)) {
239
- reference(ctx, node);
273
+ for (let [identifier, found] of bindings.origins) {
274
+ let kind = scope.kind(bindings, found);
240
275
 
241
- return;
276
+ if (kind !== TYPES.Signal && kind !== TYPES.Computed) {
277
+ continue;
278
+ }
279
+
280
+ let parent = identifier.parent!;
281
+
282
+ if (ts.isBindingElement(parent) && parent.propertyName === identifier) {
283
+ ctx.failures.push({ message: 'a reactive binding cannot be destructured (it would copy its signal); read it through the namespace', node: identifier });
284
+ continue;
285
+ }
286
+
287
+ reference(ctx, readNode(identifier), kind);
242
288
  }
243
289
 
244
- node.forEachChild(child => rewrite(ctx, child));
290
+ unpacked(ctx, bindings.sourceFile);
245
291
  }
246
292
 
247
- // The variable a reactive() call initializes: `let x = reactive(...)` or `x = reactive(...)`
248
- function targetOf(call: ts.CallExpression): ts.Identifier | null {
249
- let parent = call.parent;
293
+ // `const { count } = ns` names no property to resolve, so each element of a pattern destructuring
294
+ // a module namespace is resolved as the export it copies
295
+ function unpacked(ctx: TransformContext, node: ts.Node): void {
296
+ if (ts.isBindingElement(node) && !node.propertyName && ts.isObjectBindingPattern(node.parent)) {
297
+ let initializer = (node.parent.parent as { initializer?: ts.Expression }).initializer;
250
298
 
251
- if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
252
- return parent.name;
253
- }
299
+ if (initializer && isNamespace(ctx.bindings, initializer)) {
300
+ let kind = scope.kind(ctx.bindings, scope.origin(ctx.bindings, node));
254
301
 
255
- if (parent && ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(parent.left)) {
256
- return parent.left;
302
+ if (kind === TYPES.Signal || kind === TYPES.Computed) {
303
+ ctx.failures.push({ message: 'a reactive binding cannot be destructured (it would copy its signal); read it through the namespace', node });
304
+ }
305
+ }
257
306
  }
258
307
 
259
- return null;
308
+ node.forEachChild(child => unpacked(ctx, child));
260
309
  }
261
310
 
262
311
 
@@ -264,13 +313,15 @@ export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: I
264
313
  let ctx: TransformContext = {
265
314
  bindings,
266
315
  calls: [],
316
+ failures: [],
267
317
  isReactiveCall,
268
318
  replacements: [],
269
319
  tmpCounter: 0
270
320
  };
271
321
 
272
322
  declare(ctx, sourceFile);
273
- rewrite(ctx, sourceFile);
323
+ rewrite(ctx);
274
324
 
275
- return { calls: ctx.calls, replacements: ctx.replacements };
325
+ return { calls: ctx.calls, failures: ctx.failures, replacements: ctx.replacements };
276
326
  };
327
+ export type { Failure };
@@ -1,12 +1,15 @@
1
1
  import type { ts } from '@esportsplus/typescript';
2
- import type { TYPES } from './constants';
2
+ import type { Origin } from '@esportsplus/typescript/compiler';
3
3
 
4
4
 
5
+ // What a file is compiled against: its values resolved to their declarations, and the
6
+ // declarations `reactive` resolves to (one per installed copy of the package)
5
7
  type Bindings = {
6
8
  checker: ts.Checker;
7
- names: Set<string>;
8
- paths: Map<ts.Symbol, Set<string>>;
9
- symbols: Map<ts.Symbol, TYPES>;
9
+ entries: Set<string>;
10
+ origins: Map<ts.Node, Origin>;
11
+ program: ts.Program;
12
+ sourceFile: ts.SourceFile;
10
13
  };
11
14
 
12
15
  type IsReactiveCall = (node: ts.Node) => node is ts.CallExpression;
package/src/types.ts CHANGED
@@ -43,7 +43,8 @@ declare const READONLY: unique symbol;
43
43
  type Reactive<T> = T extends (...args: never[]) => infer R
44
44
  ? Settled<R> & { readonly [READONLY]: true }
45
45
  : T extends (infer U)[]
46
- ? U[] & Pick<ReactiveArray<U>, 'clear' | 'dispose' | 'on' | 'once'>
46
+ // `$length` / `$set` are what the compiler lowers `.length` and element writes to
47
+ ? U[] & Pick<ReactiveArray<U>, '$length' | '$set' | 'clear' | 'dispose' | 'on' | 'once'>
47
48
  : T extends Record<PropertyKey, unknown>
48
49
  ? { [K in keyof T]: T[K] extends (infer U)[] ? Reactive<U[]> : T[K]; } & { dispose: VoidFunction }
49
50
  : T;