@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.
@@ -1,21 +1,56 @@
1
1
  import { ts } from '@esportsplus/typescript';
2
2
  import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
3
- import { COMPOUND_OPERATORS, NAMESPACE, TYPES } from './constants';
3
+ import { COMPOUND_OPERATORS, NAMESPACE } from './constants';
4
4
  import type { Bindings, IsReactiveCall } from './types';
5
+ import type { Failure } from './primitives';
5
6
  import scope from './bindings';
6
7
 
7
8
 
8
- type VisitContext = {
9
- bindings: Bindings;
10
- isReactiveCall: IsReactiveCall;
9
+ type ArrayTransformResult = {
10
+ failures: Failure[];
11
+ // Whether the output references the runtime namespace (ReactiveArray construction)
12
+ namespaced: boolean;
13
+ // The array expressions found reactive by type
14
+ reactive: ts.Expression[];
11
15
  replacements: ReplacementIntent[];
12
- sourceFile: ts.SourceFile;
13
16
  };
14
17
 
18
+ // Sites that must go through a ReactiveArray's tracking API when their array is reactive
19
+ type Candidates = {
20
+ // `arr.length` reads and writes
21
+ lengths: ts.PropertyAccessExpression[];
22
+ // `arr = [...]`
23
+ reassignments: ts.BinaryExpression[];
24
+ // `arr[i] = v`, `arr[i] op= v`, `arr[i]++`, `delete arr[i]`
25
+ writes: ts.ElementAccessExpression[];
26
+ };
27
+
28
+
29
+ function collect(node: ts.Node, candidates: Candidates): void {
30
+ if (ts.isTypeNode(node)) {
31
+ return;
32
+ }
33
+
34
+ if (ts.isPropertyAccessExpression(node) && node.name.text === 'length') {
35
+ candidates.lengths.push(node);
36
+ }
37
+ else if (ts.isElementAccessExpression(node) && isWritten(node)) {
38
+ candidates.writes.push(node);
39
+ }
40
+ else if (
41
+ ts.isBinaryExpression(node) &&
42
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
43
+ ts.isIdentifier(node.left) &&
44
+ ts.isArrayLiteralExpression(scope.unwrap(node.right))
45
+ ) {
46
+ candidates.reassignments.push(node);
47
+ }
15
48
 
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 {
49
+ node.forEachChild(child => collect(child, candidates));
50
+ }
51
+
52
+ // Pass 1: reactive([...]) literals become ReactiveArray instances
53
+ function declare(ctx: { isReactiveCall: IsReactiveCall; replacements: ReplacementIntent[]; sourceFile: ts.SourceFile }, node: ts.Node): void {
19
54
  if (ctx.isReactiveCall(node) && node.arguments.length > 0) {
20
55
  let arg = node.arguments[0],
21
56
  expression = ts.isAsExpression(arg) ? arg.expression : arg;
@@ -30,10 +65,6 @@ function declare(ctx: VisitContext, node: ts.Node): void {
30
65
  elementType = getElementTypeText(node.parent.type, ctx.sourceFile);
31
66
  }
32
67
 
33
- if (node.parent && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
34
- scope.declare(ctx.bindings, node.parent.name, TYPES.Array);
35
- }
36
-
37
68
  let typeParam = elementType ? `<${elementType}>` : '';
38
69
 
39
70
  ctx.replacements.push({
@@ -45,32 +76,6 @@ function declare(ctx: VisitContext, node: ts.Node): void {
45
76
  }
46
77
  }
47
78
 
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);
56
- }
57
-
58
- if ((ts.isFunctionDeclaration(node) || ts.isArrowFunction(node)) && node.parameters) {
59
- for (let i = 0, n = node.parameters.length; i < n; i++) {
60
- let param = node.parameters[i];
61
-
62
- if (
63
- ts.isIdentifier(param.name) &&
64
- param.type &&
65
- ts.isTypeReferenceNode(param.type) &&
66
- ts.isIdentifier(param.type.typeName) &&
67
- param.type.typeName.text === 'ReactiveArray'
68
- ) {
69
- scope.declare(ctx.bindings, param.name, TYPES.Array);
70
- }
71
- }
72
- }
73
-
74
79
  node.forEachChild(child => declare(ctx, child));
75
80
  }
76
81
 
@@ -92,108 +97,149 @@ function getElementTypeText(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): s
92
97
  return null;
93
98
  }
94
99
 
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
- }
100
+ function isWritten(access: ts.ElementAccessExpression): boolean {
101
+ let parent = access.parent;
100
102
 
101
- if (ts.isPropertyAccessExpression(node) && node.name.text === 'length' && scope.isArray(ctx.bindings, node.expression)) {
102
- let expr = node.expression,
103
- parent = node.parent;
103
+ if (!parent) {
104
+ return false;
105
+ }
104
106
 
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);
107
+ if (ts.isBinaryExpression(parent)) {
108
+ let op = parent.operatorToken.kind;
108
109
 
109
- if (op === undefined) {
110
- ctx.replacements.push({
111
- node: parent,
112
- generate: (sf) => `${expr.getText(sf)}.$length = ${parent.right.getText(sf)}`
113
- });
114
- }
115
- else {
116
- ctx.replacements.push({
117
- node: parent,
118
- generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} ${parent.right.getText(sf)}`
119
- });
120
- }
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 ? '+' : '-';
110
+ return parent.left === access && (op === ts.SyntaxKind.EqualsToken || COMPOUND_OPERATORS.has(op));
111
+ }
125
112
 
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
- }
113
+ if (ts.isPostfixUnaryExpression(parent) || ts.isPrefixUnaryExpression(parent)) {
114
+ return parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken;
138
115
  }
139
116
 
140
- if (
141
- ts.isBinaryExpression(node) &&
142
- node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
143
- ts.isElementAccessExpression(node.left) &&
144
- scope.isArray(ctx.bindings, node.left.expression)
117
+ return ts.isDeleteExpression(parent);
118
+ }
119
+
120
+ function length(access: ts.PropertyAccessExpression, replacements: ReplacementIntent[]): void {
121
+ let expr = access.expression,
122
+ parent = access.parent;
123
+
124
+ // arr.length = value OR arr.length op= value
125
+ if (parent && ts.isBinaryExpression(parent) && parent.left === access && (parent.operatorToken.kind === ts.SyntaxKind.EqualsToken || COMPOUND_OPERATORS.has(parent.operatorToken.kind))) {
126
+ let op = COMPOUND_OPERATORS.get(parent.operatorToken.kind);
127
+
128
+ replacements.push({
129
+ node: parent,
130
+ generate: (sf) => op === undefined
131
+ ? `${expr.getText(sf)}.$length = ${parent.right.getText(sf)}`
132
+ : `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} ${parent.right.getText(sf)}`
133
+ });
134
+ }
135
+ // arr.length++ / arr.length-- / ++arr.length / --arr.length
136
+ else if (
137
+ parent &&
138
+ (ts.isPostfixUnaryExpression(parent) || ts.isPrefixUnaryExpression(parent)) &&
139
+ (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken)
145
140
  ) {
146
- let element = node.left;
141
+ let op = parent.operator === ts.SyntaxKind.PlusPlusToken ? '+' : '-';
142
+
143
+ replacements.push({
144
+ node: parent,
145
+ generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} 1`
146
+ });
147
+ }
148
+ // Read-only: arr.length -> arr.$length
149
+ else {
150
+ replacements.push({
151
+ node: access,
152
+ generate: (sf) => `${expr.getText(sf)}.$length`
153
+ });
154
+ }
155
+ }
156
+
157
+ // An element write must notify, so it goes through $set; operands are evaluated once
158
+ function write(access: ts.ElementAccessExpression, ctx: ArrayTransformResult): void {
159
+ let parent = access.parent!,
160
+ array = (sf: ts.SourceFile) => access.expression.getText(sf),
161
+ index = (sf: ts.SourceFile) => access.argumentExpression.getText(sf);
162
+
163
+ if (ts.isDeleteExpression(parent)) {
164
+ ctx.failures.push({ message: 'an element of a reactive array cannot be deleted; use splice()', node: parent });
165
+ return;
166
+ }
167
+
168
+ if (ts.isBinaryExpression(parent)) {
169
+ let op = COMPOUND_OPERATORS.get(parent.operatorToken.kind),
170
+ right = parent.right;
147
171
 
148
172
  ctx.replacements.push({
149
- node,
150
- generate: (sf) => `${element.expression.getText(sf)}.$set(
151
- ${element.argumentExpression.getText(sf)},
152
- ${node.right.getText(sf)}
153
- )`
173
+ node: parent,
174
+ generate: (sf) => op === undefined
175
+ ? `${array(sf)}.$set(${index(sf)}, ${right.getText(sf)})`
176
+ : `((_a, _k) => _a.$set(_k, _a[_k] ${op} (${right.getText(sf)})))(${array(sf)}, ${index(sf)})`
154
177
  });
178
+
179
+ return;
155
180
  }
156
181
 
157
- if (
158
- ts.isBinaryExpression(node) &&
159
- node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
160
- ts.isIdentifier(node.left) &&
161
- scope.isArray(ctx.bindings, node.left)
162
- ) {
163
- let name = node.left.text,
164
- right = node.right;
182
+ let unary = parent as ts.PostfixUnaryExpression | ts.PrefixUnaryExpression,
183
+ op = unary.operator === ts.SyntaxKind.PlusPlusToken ? '+' : '-';
165
184
 
166
- // Unwrap "as" expressions: arr = [] as Type[]
167
- while (ts.isAsExpression(right) || ts.isTypeAssertion(right)) {
168
- right = right.expression;
169
- }
185
+ ctx.replacements.push({
186
+ node: unary,
187
+ generate: (sf) => ts.isPrefixUnaryExpression(unary)
188
+ ? `((_a, _k) => (_a.$set(_k, _a[_k] ${op} 1), _a[_k]))(${array(sf)}, ${index(sf)})`
189
+ : `((_a, _k, _v) => (_a.$set(_k, _v ${op} 1), _v))(${array(sf)}, ${index(sf)}, ${array(sf)}[${index(sf)}])`
190
+ });
191
+ }
170
192
 
171
- if (ts.isArrayLiteralExpression(right)) {
172
- let elements = right;
173
193
 
174
- ctx.replacements.push({
175
- node,
176
- generate: (sf) => elements.elements.length > 0
177
- ? `${name}.splice(0, ${name}.length, ...${elements.getText(sf)})`
178
- : `${name}.splice(0, ${name}.length)`
179
- });
194
+ // Reactive arrays are recognized by type, so `.length` and element writes compile wherever the
195
+ // array came from: a local reactive([...]), an import, an object property, a parameter
196
+ export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: IsReactiveCall): ArrayTransformResult => {
197
+ let candidates: Candidates = { lengths: [], reassignments: [], writes: [] },
198
+ result: ArrayTransformResult = { failures: [], namespaced: false, reactive: [], replacements: [] };
199
+
200
+ declare({ isReactiveCall, replacements: result.replacements, sourceFile }, sourceFile);
201
+
202
+ result.namespaced = result.replacements.length > 0;
203
+ collect(sourceFile, candidates);
204
+
205
+ let reactive = scope.arrays(bindings, [
206
+ ...candidates.lengths.map(access => access.expression),
207
+ ...candidates.reassignments.map(assignment => assignment.left),
208
+ ...candidates.writes.map(access => access.expression)
209
+ ]);
210
+
211
+ result.reactive = [...reactive] as ts.Expression[];
212
+
213
+ for (let i = 0, n = candidates.lengths.length; i < n; i++) {
214
+ if (reactive.has(candidates.lengths[i].expression)) {
215
+ length(candidates.lengths[i], result.replacements);
180
216
  }
181
217
  }
182
218
 
183
- node.forEachChild(child => rewrite(ctx, child));
184
- }
219
+ for (let i = 0, n = candidates.writes.length; i < n; i++) {
220
+ if (reactive.has(candidates.writes[i].expression)) {
221
+ write(candidates.writes[i], result);
222
+ }
223
+ }
224
+
225
+ // `arr = [...]` refills the same ReactiveArray so everything holding it stays live
226
+ for (let i = 0, n = candidates.reassignments.length; i < n; i++) {
227
+ let assignment = candidates.reassignments[i];
185
228
 
229
+ if (!reactive.has(assignment.left) || isReactiveCall(scope.unwrap(assignment.right))) {
230
+ continue;
231
+ }
186
232
 
187
- export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: IsReactiveCall): ReplacementIntent[] => {
188
- let ctx: VisitContext = {
189
- bindings,
190
- isReactiveCall,
191
- replacements: [],
192
- sourceFile
193
- };
233
+ let name = (assignment.left as ts.Identifier).text,
234
+ elements = scope.unwrap(assignment.right) as ts.ArrayLiteralExpression;
194
235
 
195
- declare(ctx, sourceFile);
196
- rewrite(ctx, sourceFile);
236
+ result.replacements.push({
237
+ node: assignment,
238
+ generate: (sf) => elements.elements.length > 0
239
+ ? `${name}.splice(0, ${name}.length, ...${elements.getText(sf)})`
240
+ : `${name}.splice(0, ${name}.length)`
241
+ });
242
+ }
197
243
 
198
- return ctx.replacements;
244
+ return result;
199
245
  };