@esportsplus/reactivity 0.37.0 → 0.37.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.
@@ -1,8 +1,8 @@
1
1
  import { ts } from '@esportsplus/typescript';
2
- import { ast } from '@esportsplus/typescript/compiler';
3
2
  import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
4
3
  import { COMPOUND_OPERATORS, NAMESPACE, TYPES } from './constants';
5
4
  import type { Bindings, IsReactiveCall } from './types';
5
+ import scope from './bindings';
6
6
 
7
7
 
8
8
  type VisitContext = {
@@ -13,25 +13,9 @@ type VisitContext = {
13
13
  };
14
14
 
15
15
 
16
- function getElementTypeText(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string | null {
17
- if (ts.isArrayTypeNode(typeNode)) {
18
- return typeNode.elementType.getText(sourceFile);
19
- }
20
-
21
- if (
22
- ts.isTypeReferenceNode(typeNode) &&
23
- ts.isIdentifier(typeNode.typeName) &&
24
- typeNode.typeName.text === 'Array' &&
25
- typeNode.typeArguments &&
26
- typeNode.typeArguments.length > 0
27
- ) {
28
- return typeNode.typeArguments[0].getText(sourceFile);
29
- }
30
-
31
- return null;
32
- }
33
-
34
- function visit(ctx: VisitContext, node: ts.Node): void {
16
+ // Pass 1, in source order so an alias (`let b = a`) sees the array it copies: reactive([...])
17
+ // declarations, `ReactiveArray`-typed parameters, and aliases of an existing array binding
18
+ function declare(ctx: VisitContext, node: ts.Node): void {
35
19
  if (ctx.isReactiveCall(node) && node.arguments.length > 0) {
36
20
  let arg = node.arguments[0],
37
21
  expression = ts.isAsExpression(arg) ? arg.expression : arg;
@@ -47,7 +31,7 @@ function visit(ctx: VisitContext, node: ts.Node): void {
47
31
  }
48
32
 
49
33
  if (node.parent && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
50
- ctx.bindings.set(node.parent.name.text, TYPES.Array);
34
+ scope.declare(ctx.bindings, node.parent.name, TYPES.Array);
51
35
  }
52
36
 
53
37
  let typeParam = elementType ? `<${elementType}>` : '';
@@ -61,18 +45,14 @@ function visit(ctx: VisitContext, node: ts.Node): void {
61
45
  }
62
46
  }
63
47
 
64
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
65
- if (ts.isIdentifier(node.initializer) && ctx.bindings.get(node.initializer.text) === TYPES.Array) {
66
- ctx.bindings.set(node.name.text, TYPES.Array);
67
- }
68
-
69
- if (ts.isPropertyAccessExpression(node.initializer)) {
70
- let path = ast.property.path(node.initializer);
71
-
72
- if (path && ctx.bindings.get(path) === TYPES.Array) {
73
- ctx.bindings.set(node.name.text, TYPES.Array);
74
- }
75
- }
48
+ if (
49
+ ts.isVariableDeclaration(node) &&
50
+ ts.isIdentifier(node.name) &&
51
+ node.initializer &&
52
+ (ts.isIdentifier(node.initializer) || ts.isPropertyAccessExpression(node.initializer)) &&
53
+ scope.isArray(ctx.bindings, node.initializer)
54
+ ) {
55
+ scope.declare(ctx.bindings, node.name, TYPES.Array);
76
56
  }
77
57
 
78
58
  if ((ts.isFunctionDeclaration(node) || ts.isArrowFunction(node)) && node.parameters) {
@@ -80,91 +60,105 @@ function visit(ctx: VisitContext, node: ts.Node): void {
80
60
  let param = node.parameters[i];
81
61
 
82
62
  if (
83
- (ts.isIdentifier(param.name) && param.type) &&
63
+ ts.isIdentifier(param.name) &&
64
+ param.type &&
84
65
  ts.isTypeReferenceNode(param.type) &&
85
66
  ts.isIdentifier(param.type.typeName) &&
86
67
  param.type.typeName.text === 'ReactiveArray'
87
68
  ) {
88
- ctx.bindings.set(param.name.text, TYPES.Array);
69
+ scope.declare(ctx.bindings, param.name, TYPES.Array);
89
70
  }
90
71
  }
91
72
  }
92
73
 
93
- if (ts.isPropertyAccessExpression(node) && node.name.text === 'length') {
94
- let name = ast.expression.name(node.expression);
95
-
96
- if (name && ctx.bindings.get(name) === TYPES.Array) {
97
- let expr = node.expression,
98
- parent = node.parent;
99
-
100
- // arr.length = value OR arr.length += value
101
- if (parent && ts.isBinaryExpression(parent) && parent.left === node && (parent.operatorToken.kind === ts.SyntaxKind.EqualsToken || COMPOUND_OPERATORS.has(parent.operatorToken.kind))) {
102
- let op = COMPOUND_OPERATORS.get(parent.operatorToken.kind);
103
-
104
- if (op === undefined) {
105
- ctx.replacements.push({
106
- node: parent,
107
- generate: (sf) => `${expr.getText(sf)}.$length = ${parent.right.getText(sf)}`
108
- });
109
- }
110
- else {
111
- ctx.replacements.push({
112
- node: parent,
113
- generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} ${parent.right.getText(sf)}`
114
- });
115
- }
116
- }
117
- // arr.length++ or arr.length--
118
- else if (parent && ts.isPostfixUnaryExpression(parent)) {
119
- let op = parent.operator === ts.SyntaxKind.PlusPlusToken ? '+' : '-';
74
+ node.forEachChild(child => declare(ctx, child));
75
+ }
120
76
 
121
- ctx.replacements.push({
122
- node: parent,
123
- generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} 1`
124
- });
125
- }
126
- // ++arr.length or --arr.length
127
- else if (parent && ts.isPrefixUnaryExpression(parent)) {
128
- let op = parent.operator === ts.SyntaxKind.PlusPlusToken ? '+' : '-';
77
+ function getElementTypeText(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string | null {
78
+ if (ts.isArrayTypeNode(typeNode)) {
79
+ return typeNode.elementType.getText(sourceFile);
80
+ }
81
+
82
+ if (
83
+ ts.isTypeReferenceNode(typeNode) &&
84
+ ts.isIdentifier(typeNode.typeName) &&
85
+ typeNode.typeName.text === 'Array' &&
86
+ typeNode.typeArguments &&
87
+ typeNode.typeArguments.length > 0
88
+ ) {
89
+ return typeNode.typeArguments[0].getText(sourceFile);
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ // Pass 2: rewrite `.length`, index writes and whole-array reassignment on array bindings only
96
+ function rewrite(ctx: VisitContext, node: ts.Node): void {
97
+ if (ts.isTypeNode(node)) {
98
+ return;
99
+ }
129
100
 
101
+ if (ts.isPropertyAccessExpression(node) && node.name.text === 'length' && scope.isArray(ctx.bindings, node.expression)) {
102
+ let expr = node.expression,
103
+ parent = node.parent;
104
+
105
+ // arr.length = value OR arr.length += value
106
+ if (parent && ts.isBinaryExpression(parent) && parent.left === node && (parent.operatorToken.kind === ts.SyntaxKind.EqualsToken || COMPOUND_OPERATORS.has(parent.operatorToken.kind))) {
107
+ let op = COMPOUND_OPERATORS.get(parent.operatorToken.kind);
108
+
109
+ if (op === undefined) {
130
110
  ctx.replacements.push({
131
111
  node: parent,
132
- generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} 1`
112
+ generate: (sf) => `${expr.getText(sf)}.$length = ${parent.right.getText(sf)}`
133
113
  });
134
114
  }
135
- // Read-only: arr.length → arr.$length
136
115
  else {
137
116
  ctx.replacements.push({
138
- node,
139
- generate: (sf) => `${expr.getText(sf)}.$length`
117
+ node: parent,
118
+ generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} ${parent.right.getText(sf)}`
140
119
  });
141
120
  }
142
121
  }
122
+ // arr.length++ / arr.length-- / ++arr.length / --arr.length
123
+ else if (parent && (ts.isPostfixUnaryExpression(parent) || ts.isPrefixUnaryExpression(parent))) {
124
+ let op = parent.operator === ts.SyntaxKind.PlusPlusToken ? '+' : '-';
125
+
126
+ ctx.replacements.push({
127
+ node: parent,
128
+ generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} 1`
129
+ });
130
+ }
131
+ // Read-only: arr.length → arr.$length
132
+ else {
133
+ ctx.replacements.push({
134
+ node,
135
+ generate: (sf) => `${expr.getText(sf)}.$length`
136
+ });
137
+ }
143
138
  }
144
139
 
145
140
  if (
146
141
  ts.isBinaryExpression(node) &&
147
142
  node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
148
- ts.isElementAccessExpression(node.left)
143
+ ts.isElementAccessExpression(node.left) &&
144
+ scope.isArray(ctx.bindings, node.left.expression)
149
145
  ) {
150
- let element = node.left,
151
- name = ast.expression.name(element.expression);
146
+ let element = node.left;
152
147
 
153
- if (name && ctx.bindings.get(name) === TYPES.Array) {
154
- ctx.replacements.push({
155
- node,
156
- generate: (sf) => `${element.expression.getText(sf)}.$set(
148
+ ctx.replacements.push({
149
+ node,
150
+ generate: (sf) => `${element.expression.getText(sf)}.$set(
157
151
  ${element.argumentExpression.getText(sf)},
158
152
  ${node.right.getText(sf)}
159
153
  )`
160
- });
161
- }
154
+ });
162
155
  }
163
156
 
164
157
  if (
165
158
  ts.isBinaryExpression(node) &&
166
159
  node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
167
- ts.isIdentifier(node.left)
160
+ ts.isIdentifier(node.left) &&
161
+ scope.isArray(ctx.bindings, node.left)
168
162
  ) {
169
163
  let name = node.left.text,
170
164
  right = node.right;
@@ -174,17 +168,19 @@ function visit(ctx: VisitContext, node: ts.Node): void {
174
168
  right = right.expression;
175
169
  }
176
170
 
177
- if (ctx.bindings.get(name) === TYPES.Array && ts.isArrayLiteralExpression(right)) {
171
+ if (ts.isArrayLiteralExpression(right)) {
172
+ let elements = right;
173
+
178
174
  ctx.replacements.push({
179
175
  node,
180
- generate: (sf) => right.elements.length > 0
181
- ? `${name}.splice(0, ${name}.length, ...${right.getText(sf)})`
176
+ generate: (sf) => elements.elements.length > 0
177
+ ? `${name}.splice(0, ${name}.length, ...${elements.getText(sf)})`
182
178
  : `${name}.splice(0, ${name}.length)`
183
179
  });
184
180
  }
185
181
  }
186
182
 
187
- node.forEachChild(n => visit(ctx, n));
183
+ node.forEachChild(child => rewrite(ctx, child));
188
184
  }
189
185
 
190
186
 
@@ -196,7 +192,8 @@ export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: I
196
192
  sourceFile
197
193
  };
198
194
 
199
- visit(ctx, sourceFile);
195
+ declare(ctx, sourceFile);
196
+ rewrite(ctx, sourceFile);
200
197
 
201
198
  return ctx.replacements;
202
199
  };
@@ -0,0 +1,100 @@
1
+ import { ts } from '@esportsplus/typescript';
2
+ import { TYPES } from './constants';
3
+ import type { Bindings } from './types';
4
+
5
+
6
+ function root(expression: ts.Expression): { path: string; root: ts.Identifier } | null {
7
+ let parts: string[] = [];
8
+
9
+ while (ts.isPropertyAccessExpression(expression)) {
10
+ parts.push(expression.name.text);
11
+ expression = expression.expression;
12
+ }
13
+
14
+ if (!ts.isIdentifier(expression) || parts.length === 0) {
15
+ return null;
16
+ }
17
+
18
+ return { path: parts.reverse().join('.'), root: expression };
19
+ }
20
+
21
+ function symbolOf(bindings: Bindings, identifier: ts.Identifier): ts.Symbol | undefined {
22
+ let parent = identifier.parent;
23
+
24
+ // A shorthand property's own symbol is the object property, not the variable it reads
25
+ if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) {
26
+ return bindings.checker.getShorthandAssignmentValueSymbol(parent);
27
+ }
28
+
29
+ return bindings.checker.getSymbolAtLocation(identifier);
30
+ }
31
+
32
+
33
+ // Bindings are keyed by symbol, so a shadowing local, parameter or property that merely shares a
34
+ // reactive variable's name is never rewritten. `names` keeps resolution cheap: an identifier whose
35
+ // text no reactive declaration uses cannot refer to one, so it never costs a checker round-trip.
36
+ const create = (checker: ts.Checker): Bindings => ({
37
+ checker,
38
+ names: new Set(),
39
+ paths: new Map(),
40
+ symbols: new Map()
41
+ });
42
+
43
+ const declare = (bindings: Bindings, identifier: ts.Identifier, type: TYPES): void => {
44
+ let symbol = bindings.checker.getSymbolAtLocation(identifier);
45
+
46
+ if (symbol) {
47
+ bindings.names.add(identifier.text);
48
+ bindings.symbols.set(symbol, type);
49
+ }
50
+ };
51
+
52
+ // `state.items` where `state` is a reactive object whose `items` property is a ReactiveArray
53
+ const declarePath = (bindings: Bindings, identifier: ts.Identifier, path: string): void => {
54
+ let symbol = bindings.checker.getSymbolAtLocation(identifier);
55
+
56
+ if (!symbol) {
57
+ return;
58
+ }
59
+
60
+ let paths = bindings.paths.get(symbol);
61
+
62
+ if (!paths) {
63
+ paths = new Set();
64
+ bindings.paths.set(symbol, paths);
65
+ }
66
+
67
+ bindings.names.add(identifier.text);
68
+ paths.add(path);
69
+ };
70
+
71
+ // True for an identifier or property path that denotes a ReactiveArray binding
72
+ const isArray = (bindings: Bindings, expression: ts.Expression): boolean => {
73
+ if (ts.isIdentifier(expression)) {
74
+ return resolve(bindings, expression)?.type === TYPES.Array;
75
+ }
76
+
77
+ let access = root(expression);
78
+
79
+ if (!access || !bindings.names.has(access.root.text)) {
80
+ return false;
81
+ }
82
+
83
+ let symbol = symbolOf(bindings, access.root);
84
+
85
+ return symbol !== undefined && bindings.paths.get(symbol)?.has(access.path) === true;
86
+ };
87
+
88
+ const resolve = (bindings: Bindings, identifier: ts.Identifier): { symbol: ts.Symbol; type: TYPES } | null => {
89
+ if (!bindings.names.has(identifier.text)) {
90
+ return null;
91
+ }
92
+
93
+ let symbol = symbolOf(bindings, identifier),
94
+ type = symbol && bindings.symbols.get(symbol);
95
+
96
+ return symbol && type !== undefined ? { symbol, type } : null;
97
+ };
98
+
99
+
100
+ export default { create, declare, declarePath, isArray, resolve };
@@ -2,13 +2,22 @@ import { ts } from '@esportsplus/typescript';
2
2
  import { imports } from '@esportsplus/typescript/compiler';
3
3
  import type { ImportIntent, ReplacementIntent, TransformContext } from '@esportsplus/typescript/compiler';
4
4
  import { ENTRYPOINT, NAMESPACE, PACKAGE_NAME } from './constants';
5
- import type { Bindings } from './types';
6
5
 
7
6
  import array from './array';
8
7
  import object from './object';
9
8
  import primitives from './primitives';
9
+ import scope from './bindings';
10
10
 
11
11
 
12
+ // Resolved once per file: every pass asks the same question of the same nodes
13
+ function collect(checker: ts.Checker, node: ts.Node, calls: Set<ts.Node>): void {
14
+ if (isReactiveCallExpression(checker, node)) {
15
+ calls.add(node);
16
+ }
17
+
18
+ node.forEachChild(child => collect(checker, child, calls));
19
+ }
20
+
12
21
  function isReactiveCallExpression(checker: ts.Checker, node: ts.Node): node is ts.CallExpression {
13
22
  if (!ts.isCallExpression(node)) {
14
23
  return false;
@@ -21,9 +30,9 @@ function isReactiveCallExpression(checker: ts.Checker, node: ts.Node): node is t
21
30
  return imports.includes(checker, expr, PACKAGE_NAME, ENTRYPOINT);
22
31
  }
23
32
 
24
- // Property access: ns.reactive(...)
25
- if (ts.isPropertyAccessExpression(expr) && expr.name.text === ENTRYPOINT) {
26
- return imports.includes(checker, expr, PACKAGE_NAME);
33
+ // Namespace call: ns.reactive(...) — the namespace identifier carries the import origin
34
+ if (ts.isPropertyAccessExpression(expr) && expr.name.text === ENTRYPOINT && ts.isIdentifier(expr.expression)) {
35
+ return imports.includes(checker, expr.expression, PACKAGE_NAME);
27
36
  }
28
37
 
29
38
  return false;
@@ -39,22 +48,26 @@ export default {
39
48
  return {};
40
49
  }
41
50
 
42
- let bindings: Bindings = new Map(),
51
+ let bindings = scope.create(checker),
52
+ candidates = new Set<ts.Node>(),
43
53
  intents = {
44
54
  imports: [] as ImportIntent[],
45
55
  prepend: [] as string[],
46
56
  replacements: [] as ReplacementIntent[]
47
57
  },
48
- isReactiveCall = (node: ts.Node): node is ts.CallExpression => isReactiveCallExpression(checker, node),
49
58
  sourceFile = ctx.sourceFile;
50
59
 
51
- // Run primitives transform first (tracks bindings for signal/computed, collects every call)
52
- let { calls, replacements } = primitives(sourceFile, bindings, isReactiveCall);
60
+ collect(checker, sourceFile, candidates);
53
61
 
54
- if (calls.length === 0) {
62
+ if (candidates.size === 0) {
55
63
  return {};
56
64
  }
57
65
 
66
+ let isReactiveCall = (node: ts.Node): node is ts.CallExpression => candidates.has(node);
67
+
68
+ // Run primitives transform first (tracks bindings for signal/computed, collects every call)
69
+ let { calls, replacements } = primitives(sourceFile, bindings, isReactiveCall);
70
+
58
71
  intents.replacements.push(...replacements);
59
72
 
60
73
  // Run object transform
@@ -3,6 +3,7 @@ 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';
6
7
 
7
8
 
8
9
  interface AnalyzedProperty {
@@ -223,11 +224,10 @@ function visit(ctx: VisitContext, node: ts.Node): void {
223
224
  if (arg && ts.isObjectLiteralExpression(arg)) {
224
225
  let properties: AnalyzedProperty[] = [],
225
226
  props = arg.properties,
226
- varname: string | null = null;
227
-
228
- if (node.parent && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
229
- varname = node.parent.name.text;
230
- }
227
+ target = node.parent && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)
228
+ ? node.parent.name
229
+ : null,
230
+ varname = target?.text ?? null;
231
231
 
232
232
  for (let i = 0, n = props.length; i < n; i++) {
233
233
  let prop = props[i];
@@ -245,9 +245,14 @@ function visit(ctx: VisitContext, node: ts.Node): void {
245
245
  }
246
246
 
247
247
  properties.push(analyzed);
248
+ }
248
249
 
249
- if (analyzed.type === TYPES.Array && varname) {
250
- ctx.bindings.set(`${varname}.${analyzed.key}`, TYPES.Array);
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
+ }
251
256
  }
252
257
  }
253
258