@esportsplus/reactivity 0.37.2 → 0.38.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.
@@ -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,145 @@ 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;
100
+ function isWritten(access: ts.ElementAccessExpression): boolean {
101
+ let parent = access.parent;
102
+
103
+ if (!parent) {
104
+ return false;
99
105
  }
100
106
 
101
- if (ts.isPropertyAccessExpression(node) && node.name.text === 'length' && scope.isArray(ctx.bindings, node.expression)) {
102
- let expr = node.expression,
103
- parent = node.parent;
107
+ if (ts.isBinaryExpression(parent)) {
108
+ let op = parent.operatorToken.kind;
104
109
 
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);
110
+ return parent.left === access && (op === ts.SyntaxKind.EqualsToken || COMPOUND_OPERATORS.has(op));
111
+ }
108
112
 
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 ? '+' : '-';
113
+ if (ts.isPostfixUnaryExpression(parent) || ts.isPrefixUnaryExpression(parent)) {
114
+ return parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken;
115
+ }
125
116
 
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
- }
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
+ });
138
134
  }
135
+ // arr.length++ / arr.length-- / ++arr.length / --arr.length
136
+ else if (parent && (ts.isPostfixUnaryExpression(parent) || ts.isPrefixUnaryExpression(parent))) {
137
+ let op = parent.operator === ts.SyntaxKind.PlusPlusToken ? '+' : '-';
139
138
 
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)
145
- ) {
146
- let element = node.left;
139
+ replacements.push({
140
+ node: parent,
141
+ generate: (sf) => `${expr.getText(sf)}.$length = ${expr.getText(sf)}.length ${op} 1`
142
+ });
143
+ }
144
+ // Read-only: arr.length -> arr.$length
145
+ else {
146
+ replacements.push({
147
+ node: access,
148
+ generate: (sf) => `${expr.getText(sf)}.$length`
149
+ });
150
+ }
151
+ }
152
+
153
+ // An element write must notify, so it goes through $set; operands are evaluated once
154
+ function write(access: ts.ElementAccessExpression, ctx: ArrayTransformResult): void {
155
+ let parent = access.parent!,
156
+ array = (sf: ts.SourceFile) => access.expression.getText(sf),
157
+ index = (sf: ts.SourceFile) => access.argumentExpression.getText(sf);
158
+
159
+ if (ts.isDeleteExpression(parent)) {
160
+ ctx.failures.push({ message: 'an element of a reactive array cannot be deleted; use splice()', node: parent });
161
+ return;
162
+ }
163
+
164
+ if (ts.isBinaryExpression(parent)) {
165
+ let op = COMPOUND_OPERATORS.get(parent.operatorToken.kind),
166
+ right = parent.right;
147
167
 
148
168
  ctx.replacements.push({
149
- node,
150
- generate: (sf) => `${element.expression.getText(sf)}.$set(
151
- ${element.argumentExpression.getText(sf)},
152
- ${node.right.getText(sf)}
153
- )`
169
+ node: parent,
170
+ generate: (sf) => op === undefined
171
+ ? `${array(sf)}.$set(${index(sf)}, ${right.getText(sf)})`
172
+ : `((_a, _k) => _a.$set(_k, _a[_k] ${op} (${right.getText(sf)})))(${array(sf)}, ${index(sf)})`
154
173
  });
174
+
175
+ return;
155
176
  }
156
177
 
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;
178
+ let unary = parent as ts.PostfixUnaryExpression | ts.PrefixUnaryExpression,
179
+ op = unary.operator === ts.SyntaxKind.PlusPlusToken ? '+' : '-';
165
180
 
166
- // Unwrap "as" expressions: arr = [] as Type[]
167
- while (ts.isAsExpression(right) || ts.isTypeAssertion(right)) {
168
- right = right.expression;
169
- }
181
+ ctx.replacements.push({
182
+ node: unary,
183
+ generate: (sf) => ts.isPrefixUnaryExpression(unary)
184
+ ? `((_a, _k) => (_a.$set(_k, _a[_k] ${op} 1), _a[_k]))(${array(sf)}, ${index(sf)})`
185
+ : `((_a, _k, _v) => (_a.$set(_k, _v ${op} 1), _v))(${array(sf)}, ${index(sf)}, ${array(sf)}[${index(sf)}])`
186
+ });
187
+ }
170
188
 
171
- if (ts.isArrayLiteralExpression(right)) {
172
- let elements = right;
173
189
 
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
- });
190
+ // Reactive arrays are recognized by type, so `.length` and element writes compile wherever the
191
+ // array came from: a local reactive([...]), an import, an object property, a parameter
192
+ export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: IsReactiveCall): ArrayTransformResult => {
193
+ let candidates: Candidates = { lengths: [], reassignments: [], writes: [] },
194
+ result: ArrayTransformResult = { failures: [], namespaced: false, reactive: [], replacements: [] };
195
+
196
+ declare({ isReactiveCall, replacements: result.replacements, sourceFile }, sourceFile);
197
+
198
+ result.namespaced = result.replacements.length > 0;
199
+ collect(sourceFile, candidates);
200
+
201
+ let reactive = scope.arrays(bindings, [
202
+ ...candidates.lengths.map(access => access.expression),
203
+ ...candidates.reassignments.map(assignment => assignment.left),
204
+ ...candidates.writes.map(access => access.expression)
205
+ ]);
206
+
207
+ result.reactive = [...reactive] as ts.Expression[];
208
+
209
+ for (let i = 0, n = candidates.lengths.length; i < n; i++) {
210
+ if (reactive.has(candidates.lengths[i].expression)) {
211
+ length(candidates.lengths[i], result.replacements);
180
212
  }
181
213
  }
182
214
 
183
- node.forEachChild(child => rewrite(ctx, child));
184
- }
215
+ for (let i = 0, n = candidates.writes.length; i < n; i++) {
216
+ if (reactive.has(candidates.writes[i].expression)) {
217
+ write(candidates.writes[i], result);
218
+ }
219
+ }
220
+
221
+ // `arr = [...]` refills the same ReactiveArray so everything holding it stays live
222
+ for (let i = 0, n = candidates.reassignments.length; i < n; i++) {
223
+ let assignment = candidates.reassignments[i];
185
224
 
225
+ if (!reactive.has(assignment.left) || isReactiveCall(scope.unwrap(assignment.right))) {
226
+ continue;
227
+ }
186
228
 
187
- export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: IsReactiveCall): ReplacementIntent[] => {
188
- let ctx: VisitContext = {
189
- bindings,
190
- isReactiveCall,
191
- replacements: [],
192
- sourceFile
193
- };
229
+ let name = (assignment.left as ts.Identifier).text,
230
+ elements = scope.unwrap(assignment.right) as ts.ArrayLiteralExpression;
194
231
 
195
- declare(ctx, sourceFile);
196
- rewrite(ctx, sourceFile);
232
+ result.replacements.push({
233
+ node: assignment,
234
+ generate: (sf) => elements.elements.length > 0
235
+ ? `${name}.splice(0, ${name}.length, ...${elements.getText(sf)})`
236
+ : `${name}.splice(0, ${name}.length)`
237
+ });
238
+ }
197
239
 
198
- return ctx.replacements;
240
+ return result;
199
241
  };