@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,100 +1,303 @@
1
1
  import { ts } from '@esportsplus/typescript';
2
- import { TYPES } from './constants';
2
+ import { references } from '@esportsplus/typescript/compiler';
3
+ import type { Origin } from '@esportsplus/typescript/compiler';
4
+ import { ENTRYPOINT, PACKAGE_NAME, TYPES } from './constants';
3
5
  import type { Bindings } from './types';
4
6
 
5
7
 
6
- function root(expression: ts.Expression): { path: string; root: ts.Identifier } | null {
7
- let parts: string[] = [];
8
+ type Assignment = { call: ts.CallExpression; target: ts.Identifier };
8
9
 
9
- while (ts.isPropertyAccessExpression(expression)) {
10
- parts.push(expression.name.text);
11
- expression = expression.expression;
10
+ // Per program snapshot: answers about declarations and types hold for the snapshot's lifetime
11
+ type Caches = {
12
+ arrays: Map<number, boolean>;
13
+ assignments: Map<string, Map<string, Assignment[]>>;
14
+ kinds: Map<string, TYPES | null>;
15
+ };
16
+
17
+
18
+ const ARRAY_BRAND = 'once';
19
+
20
+ const REACTIVE_ARRAY = 'ReactiveArray';
21
+
22
+
23
+ let caches = new WeakMap<ts.Program, Caches>();
24
+
25
+
26
+ // `x = reactive(...)` assignments in a file, keyed by the assigned name: a variable declared
27
+ // without a reactive initializer becomes a binding through one of these
28
+ function assignments(bindings: Bindings, file: ts.SourceFile): Map<string, Assignment[]> {
29
+ let cache = cachesOf(bindings.program).assignments,
30
+ found = cache.get(file.fileName);
31
+
32
+ if (found) {
33
+ return found;
12
34
  }
13
35
 
14
- if (!ts.isIdentifier(expression) || parts.length === 0) {
15
- return null;
36
+ let result = new Map<string, Assignment[]>(),
37
+ visit = (node: ts.Node): void => {
38
+ if (
39
+ ts.isBinaryExpression(node) &&
40
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
41
+ ts.isIdentifier(node.left)
42
+ ) {
43
+ let call = unwrap(node.right);
44
+
45
+ if (ts.isCallExpression(call)) {
46
+ let list = result.get(node.left.text);
47
+
48
+ if (!list) {
49
+ list = [];
50
+ result.set(node.left.text, list);
51
+ }
52
+
53
+ list.push({ call, target: node.left });
54
+ }
55
+ }
56
+
57
+ node.forEachChild(visit);
58
+ };
59
+
60
+ visit(file);
61
+ cache.set(file.fileName, result);
62
+
63
+ return result;
64
+ }
65
+
66
+ function cachesOf(program: ts.Program): Caches {
67
+ let found = caches.get(program);
68
+
69
+ if (!found) {
70
+ found = { arrays: new Map(), assignments: new Map(), kinds: new Map() };
71
+ caches.set(program, found);
16
72
  }
17
73
 
18
- return { path: parts.reverse().join('.'), root: expression };
74
+ return found;
19
75
  }
20
76
 
21
- function symbolOf(bindings: Bindings, identifier: ts.Identifier): ts.Symbol | undefined {
22
- let parent = identifier.parent;
77
+ // Every member of a union must agree: `number | undefined` is a signal, `number[] | undefined` is
78
+ // neither a signal nor an array at runtime
79
+ function kindOf(checker: ts.Checker, type: ts.Type): TYPES | null {
80
+ let members = (type.flags & ts.TypeFlags.Union) !== 0 ? (type as ts.UnionType).getTypes() : [type],
81
+ result: TYPES | null = null;
82
+
83
+ for (let i = 0, n = members.length; i < n; i++) {
84
+ let kind = kindOfType(checker, members[i]);
23
85
 
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);
86
+ if (kind === null || (result !== null && result !== kind)) {
87
+ return null;
88
+ }
89
+
90
+ result = kind;
27
91
  }
28
92
 
29
- return bindings.checker.getSymbolAtLocation(identifier);
93
+ return result;
30
94
  }
31
95
 
96
+ // Kind of one non-union type as a reactive() argument; null when the type cannot say
97
+ function kindOfType(checker: ts.Checker, type: ts.Type): TYPES | null {
98
+ if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) {
99
+ let constraint = checker.getBaseConstraintOfType(type);
32
100
 
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
- });
101
+ return constraint && constraint !== type ? kindOf(checker, constraint) : null;
102
+ }
103
+
104
+ if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Never | ts.TypeFlags.Unknown)) !== 0) {
105
+ return null;
106
+ }
42
107
 
43
- const declare = (bindings: Bindings, identifier: ts.Identifier, type: TYPES): void => {
44
- let symbol = bindings.checker.getSymbolAtLocation(identifier);
108
+ if ((type.flags & (
109
+ ts.TypeFlags.BigIntLike |
110
+ ts.TypeFlags.BooleanLike |
111
+ ts.TypeFlags.EnumLike |
112
+ ts.TypeFlags.ESSymbolLike |
113
+ ts.TypeFlags.Null |
114
+ ts.TypeFlags.NumberLike |
115
+ ts.TypeFlags.StringLike |
116
+ ts.TypeFlags.TemplateLiteral |
117
+ ts.TypeFlags.Undefined |
118
+ ts.TypeFlags.Void
119
+ )) !== 0) {
120
+ return TYPES.Signal;
121
+ }
45
122
 
46
- if (symbol) {
47
- bindings.names.add(identifier.text);
48
- bindings.symbols.set(symbol, type);
123
+ if (checker.isArrayType(type) || checker.isTupleType(type)) {
124
+ return TYPES.Array;
49
125
  }
50
- };
51
126
 
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);
127
+ if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) {
128
+ return TYPES.Computed;
129
+ }
130
+
131
+ return (type.flags & (ts.TypeFlags.Intersection | ts.TypeFlags.NonPrimitive | ts.TypeFlags.Object)) !== 0 ? TYPES.Object : null;
132
+ }
133
+
134
+
135
+
136
+ // Expressions whose type is a ReactiveArray (`Reactive<T[]>` carries the class's members), resolved
137
+ // in one checker round-trip. Found by type, a reactive array is recognized wherever it flows:
138
+ // imports, object properties, parameters, return values.
139
+ const arrays = (bindings: Bindings, nodes: ts.Node[]): Set<ts.Node> => {
140
+ let result = new Set<ts.Node>();
55
141
 
56
- if (!symbol) {
57
- return;
142
+ if (nodes.length === 0) {
143
+ return result;
58
144
  }
59
145
 
60
- let paths = bindings.paths.get(symbol);
146
+ let files = new Set(references.exported(bindings.checker, bindings.program, PACKAGE_NAME, REACTIVE_ARRAY).map(declaration => declaration.path));
61
147
 
62
- if (!paths) {
63
- paths = new Set();
64
- bindings.paths.set(symbol, paths);
148
+ if (files.size === 0) {
149
+ return result;
65
150
  }
66
151
 
67
- bindings.names.add(identifier.text);
68
- paths.add(path);
152
+ let cache = cachesOf(bindings.program).arrays,
153
+ types = bindings.checker.getTypeAtLocation(nodes);
154
+
155
+ for (let i = 0, n = nodes.length; i < n; i++) {
156
+ let type = types[i];
157
+
158
+ if (!type) {
159
+ continue;
160
+ }
161
+
162
+ let branded = cache.get(type.id);
163
+
164
+ if (branded === undefined) {
165
+ let property = bindings.checker.getPropertyOfType(bindings.checker.getNonNullableType(type) ?? type, ARRAY_BRAND);
166
+
167
+ branded = property?.declarations?.some(declaration => files.has(declaration.path)) === true;
168
+ cache.set(type.id, branded);
169
+ }
170
+
171
+ if (branded) {
172
+ result.add(nodes[i]);
173
+ }
174
+ }
175
+
176
+ return result;
69
177
  };
70
178
 
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;
179
+ // Kind of a reactive() call from its argument: literal syntax first, the argument's type otherwise.
180
+ // Null when no kind is provable (no/extra arguments, `any`, `unknown`, a mixed union).
181
+ const classify = (bindings: Bindings, call: ts.CallExpression): TYPES | null => {
182
+ let arg = call.arguments[0];
183
+
184
+ if (!arg || call.arguments.length !== 1 || ts.isSpreadElement(arg)) {
185
+ return null;
186
+ }
187
+
188
+ let value = unwrap(arg);
189
+
190
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) {
191
+ return TYPES.Computed;
75
192
  }
76
193
 
77
- let access = root(expression);
194
+ if (ts.isArrayLiteralExpression(value)) {
195
+ return TYPES.Array;
196
+ }
78
197
 
79
- if (!access || !bindings.names.has(access.root.text)) {
80
- return false;
198
+ if (ts.isObjectLiteralExpression(value)) {
199
+ return TYPES.Object;
81
200
  }
82
201
 
83
- let symbol = symbolOf(bindings, access.root);
202
+ let type = bindings.checker.getTypeAtLocation(arg);
203
+
204
+ return type ? kindOf(bindings.checker, type) : null;
205
+ };
206
+
207
+ const create = (checker: ts.Checker, program: ts.Program, sourceFile: ts.SourceFile): Bindings => ({
208
+ checker,
209
+ entries: new Set(references.exported(checker, program, PACKAGE_NAME, ENTRYPOINT).map(references.key)),
210
+ origins: references.origins(checker, program, sourceFile),
211
+ program,
212
+ sourceFile
213
+ });
214
+
215
+ // Whether a callee expression denotes `reactive`: `reactive`, an alias, `ns.reactive`, `ns['reactive']`
216
+ const denotes = (bindings: Bindings, expression: ts.Expression): boolean => {
217
+ return references.denotes(bindings.checker, bindings.program, expression, bindings.entries);
218
+ };
219
+
220
+ // Whether a value holds `reactive`: the export itself, reached through any import, re-export or
221
+ // namespace, or a const binding (`const r = reactive`, `const { reactive: r } = ns`) holding it
222
+ const holds = (bindings: Bindings, found: Origin | null): boolean => {
223
+ return references.holds(bindings.checker, bindings.program, found, bindings.entries);
224
+ };
84
225
 
85
- return symbol !== undefined && bindings.paths.get(symbol)?.has(access.path) === true;
226
+ const isReactiveCall = (bindings: Bindings, node: ts.Node): node is ts.CallExpression => {
227
+ return ts.isCallExpression(node) && denotes(bindings, node.expression);
86
228
  };
87
229
 
88
- const resolve = (bindings: Bindings, identifier: ts.Identifier): { symbol: ts.Symbol; type: TYPES } | null => {
89
- if (!bindings.names.has(identifier.text)) {
230
+ // Reactive kind of the binding a declaration introduces, wherever it is read from: a variable
231
+ // initialized (or assigned) with reactive(), or a module's `export default reactive(...)`.
232
+ // Ambient declarations (.d.ts) never are: a published package's signals ship as Signal-typed.
233
+ const kind = (bindings: Bindings, found: Origin | null): TYPES | null => {
234
+ if (!found) {
90
235
  return null;
91
236
  }
92
237
 
93
- let symbol = symbolOf(bindings, identifier),
94
- type = symbol && bindings.symbols.get(symbol);
238
+ let declaration = found.declaration;
239
+
240
+ if (
241
+ (declaration.kind !== ts.SyntaxKind.VariableDeclaration && declaration.kind !== ts.SyntaxKind.ExportAssignment) ||
242
+ declaration.path.endsWith('.d.ts')
243
+ ) {
244
+ return null;
245
+ }
246
+
247
+ let cache = cachesOf(bindings.program).kinds,
248
+ key = references.key(declaration),
249
+ known = cache.get(key);
250
+
251
+ if (known !== undefined) {
252
+ return known;
253
+ }
254
+
255
+ cache.set(key, null);
256
+
257
+ let node = declaration.resolve() as ts.Node | undefined,
258
+ result: TYPES | null = null;
259
+
260
+ if (node && ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
261
+ let initializer = node.initializer && unwrap(node.initializer);
262
+
263
+ if (initializer && isReactiveCall(bindings, initializer)) {
264
+ result = classify(bindings, initializer);
265
+ }
266
+ else {
267
+ let candidates = assignments(bindings, node.getSourceFile()).get(node.name.text) ?? [];
268
+
269
+ for (let i = 0, n = candidates.length; i < n && result === null; i++) {
270
+ let { call, target } = candidates[i],
271
+ assigned = origin(bindings, target);
272
+
273
+ if (assigned && references.key(assigned.declaration) === key && isReactiveCall(bindings, call)) {
274
+ result = classify(bindings, call);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ else if (node && ts.isExportAssignment(node)) {
280
+ let expression = unwrap(node.expression);
281
+
282
+ if (isReactiveCall(bindings, expression)) {
283
+ result = classify(bindings, expression);
284
+ }
285
+ }
286
+
287
+ cache.set(key, result);
288
+
289
+ return result;
290
+ };
291
+
292
+ const origin = (bindings: Bindings, node: ts.Node): Origin | null => {
293
+ return bindings.origins.get(node) ?? references.origin(bindings.checker, bindings.program, node);
294
+ };
295
+
95
296
 
96
- return symbol && type !== undefined ? { symbol, type } : null;
297
+ // The expression a value comes from, without parentheses, `!` and type assertions
298
+ const unwrap = (expression: ts.Expression): ts.Expression => {
299
+ return references.unwrap(expression) as ts.Expression;
97
300
  };
98
301
 
99
302
 
100
- export default { create, declare, declarePath, isArray, resolve };
303
+ export default { arrays, classify, create, denotes, holds, isReactiveCall, kind, origin, unwrap };
@@ -28,6 +28,7 @@ const NAMESPACE = uid('reactivity');
28
28
  const TYPES = {
29
29
  Array: 0,
30
30
  Computed: 1,
31
+ Object: 3,
31
32
  Signal: 2
32
33
  } as const;
33
34
 
@@ -1,7 +1,7 @@
1
1
  import { ts } from '@esportsplus/typescript';
2
- import { imports } from '@esportsplus/typescript/compiler';
3
2
  import type { ImportIntent, ReplacementIntent, TransformContext } from '@esportsplus/typescript/compiler';
4
3
  import { ENTRYPOINT, NAMESPACE, PACKAGE_NAME } from './constants';
4
+ import type { Failure } from './primitives';
5
5
 
6
6
  import array from './array';
7
7
  import object from './object';
@@ -9,81 +9,170 @@ import primitives from './primitives';
9
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);
12
+ // Every use the compiler cannot lower fails the build, all of them reported at once: shipping one
13
+ // would leave an uncompiled reactive() or an unread signal in the output
14
+ function failed(sourceFile: ts.SourceFile, failures: Failure[]): Error {
15
+ let lines: string[] = [];
16
+
17
+ for (let i = 0, n = failures.length; i < n; i++) {
18
+ let { message, node } = failures[i],
19
+ position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
20
+
21
+ lines.push(` ${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}: ${node.getText(sourceFile).split('\n')[0]}`);
16
22
  }
17
23
 
18
- node.forEachChild(child => collect(checker, child, calls));
24
+ return new Error(`${PACKAGE_NAME}: code that must be compiled cannot be:\n${lines.join('\n')}`);
25
+ }
26
+
27
+ function isConstList(node: ts.Node | undefined): boolean {
28
+ return node !== undefined && ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.Const) !== 0;
19
29
  }
20
30
 
21
- function isReactiveCallExpression(checker: ts.Checker, node: ts.Node): node is ts.CallExpression {
22
- if (!ts.isCallExpression(node)) {
31
+ // A use of `reactive` the compiler lowers: a callee, or a const alias whose own uses are sites
32
+ function isLowered(site: ts.Node): boolean {
33
+ let parent = site.parent;
34
+
35
+ while (parent && ts.isParenthesizedExpression(parent)) {
36
+ site = parent;
37
+ parent = site.parent;
38
+ }
39
+
40
+ if (!parent) {
23
41
  return false;
24
42
  }
25
43
 
26
- let expr = node.expression;
44
+ if (ts.isCallExpression(parent)) {
45
+ return parent.expression === site;
46
+ }
27
47
 
28
- // Direct call: reactive(...) or aliasedName(...)
29
- if (ts.isIdentifier(expr)) {
30
- return imports.includes(checker, expr, PACKAGE_NAME, ENTRYPOINT);
48
+ if (ts.isVariableDeclaration(parent)) {
49
+ // `const r = reactive` only: destructuring would pull a member out of the compiled value
50
+ return parent.initializer === site && ts.isIdentifier(parent.name) && isConstList(parent.parent);
31
51
  }
32
52
 
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);
53
+ if (ts.isBindingElement(parent) && parent.propertyName === site) {
54
+ let declaration: ts.Node | undefined = parent;
55
+
56
+ while (declaration && !ts.isVariableDeclaration(declaration)) {
57
+ declaration = declaration.parent;
58
+ }
59
+
60
+ return declaration !== undefined && isConstList(declaration.parent);
36
61
  }
37
62
 
38
63
  return false;
39
64
  }
40
65
 
41
66
 
67
+ // `reactive`, or the member access naming it (`ns.reactive`, `ns['reactive']`)
68
+ function siteOf(identifier: ts.Node): ts.Node {
69
+ let parent = identifier.parent!;
70
+
71
+ if (
72
+ (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
73
+ (ts.isElementAccessExpression(parent) && parent.argumentExpression === identifier)
74
+ ) {
75
+ return parent;
76
+ }
77
+
78
+ return identifier;
79
+ }
80
+
81
+
82
+ // No text patterns: a module can reach `reactive`, or read a reactive binding, under any name
83
+ // (a barrel's rename, a default re-export, a namespace), so every module is resolved against the
84
+ // program and compiled exactly where its values resolve to reactive declarations
42
85
  export default {
43
- patterns: ['reactive(', 'reactive<'],
44
86
  transform: (ctx: TransformContext) => {
45
- let checker = ctx.checker;
87
+ let { checker, program, sourceFile } = ctx,
88
+ bindings = scope.create(checker, program, sourceFile),
89
+ calls = new Set<ts.Node>(),
90
+ dependencies = new Set<string>(),
91
+ failures: Failure[] = [],
92
+ self = sourceFile.fileName.toLowerCase();
93
+
94
+ for (let [identifier, found] of bindings.origins) {
95
+ let entry = scope.holds(bindings, found);
96
+
97
+ if (!entry && scope.kind(bindings, found) === null) {
98
+ continue;
99
+ }
100
+
101
+ for (let file of [...found.through, found.declaration.path]) {
102
+ if (file.toLowerCase() !== self) {
103
+ dependencies.add(file);
104
+ }
105
+ }
46
106
 
47
- if (!checker) {
48
- return {};
107
+ if (!entry) {
108
+ continue;
109
+ }
110
+
111
+ let site = siteOf(identifier),
112
+ parent = site.parent;
113
+
114
+ while (parent && ts.isParenthesizedExpression(parent)) {
115
+ parent = parent.parent;
116
+ }
117
+
118
+ if (parent && ts.isCallExpression(parent) && scope.unwrap(parent.expression) === site) {
119
+ calls.add(parent);
120
+ }
121
+ else if (!isLowered(site)) {
122
+ failures.push({
123
+ message: `${ENTRYPOINT} is compiled away, so it can only be called (or aliased with const); it cannot be used as a value`,
124
+ node: site
125
+ });
126
+ }
49
127
  }
50
128
 
51
- let bindings = scope.create(checker),
52
- candidates = new Set<ts.Node>(),
129
+ let isReactiveCall = (node: ts.Node): node is ts.CallExpression => calls.has(node),
53
130
  intents = {
54
131
  imports: [] as ImportIntent[],
55
132
  prepend: [] as string[],
56
133
  replacements: [] as ReplacementIntent[]
57
- },
58
- sourceFile = ctx.sourceFile;
134
+ };
59
135
 
60
- collect(checker, sourceFile, candidates);
136
+ // Signals and computeds first: every call is classified and every binding read is rewritten
137
+ let lowered = primitives(sourceFile, bindings, isReactiveCall);
61
138
 
62
- if (candidates.size === 0) {
63
- return {};
64
- }
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);
139
+ failures.push(...lowered.failures);
140
+ intents.replacements.push(...lowered.replacements);
70
141
 
71
- intents.replacements.push(...replacements);
72
-
73
- // Run object transform
74
142
  let objects = object(sourceFile, bindings, isReactiveCall);
75
143
 
76
144
  intents.prepend.push(...objects.prepend);
77
145
  intents.replacements.push(...objects.replacements);
78
146
 
79
- // Run array transform separately ( avoid race conditions )
80
- intents.replacements.push(...array(sourceFile, bindings, isReactiveCall));
147
+ let arrays = array(sourceFile, bindings, isReactiveCall);
148
+
149
+ failures.push(...arrays.failures);
150
+ intents.replacements.push(...arrays.replacements);
151
+
152
+ // An array is reactive because of where it was declared; that module shapes this compile
153
+ for (let i = 0, n = arrays.reactive.length; i < n; i++) {
154
+ let root = arrays.reactive[i];
81
155
 
82
- // Calls no transform claimed fall through to the runtime reactive()
156
+ while (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root) || ts.isParenthesizedExpression(root)) {
157
+ root = root.expression;
158
+ }
159
+
160
+ let found = ts.isIdentifier(root) ? scope.origin(bindings, root) : null;
161
+
162
+ if (found && found.declaration.path.toLowerCase() !== self) {
163
+ dependencies.add(found.declaration.path);
164
+ }
165
+ }
166
+
167
+ if (failures.length > 0) {
168
+ throw failed(sourceFile, failures);
169
+ }
170
+
171
+ // An object literal the object transform cannot specialize (a spread) is built by the runtime
83
172
  let transformed = new Set(intents.replacements.map(r => r.node));
84
173
 
85
- for (let i = 0, n = calls.length; i < n; i++) {
86
- let call = calls[i];
174
+ for (let i = 0, n = lowered.calls.length; i < n; i++) {
175
+ let call = lowered.calls[i];
87
176
 
88
177
  if (transformed.has(call) || transformed.has(call.expression)) {
89
178
  continue;
@@ -95,12 +184,19 @@ export default {
95
184
  });
96
185
  }
97
186
 
98
- intents.imports.push({
99
- namespace: NAMESPACE,
100
- package: PACKAGE_NAME,
101
- remove: [ENTRYPOINT]
102
- });
187
+ if (intents.replacements.length === 0 && intents.prepend.length === 0) {
188
+ return dependencies.size > 0 ? { dependencies: [...dependencies] } : {};
189
+ }
190
+
191
+ // `.length` / element-write rewrites alone call methods on the array itself
192
+ if (calls.size > 0 || lowered.replacements.length > 0 || intents.prepend.length > 0 || arrays.namespaced) {
193
+ intents.imports.push({
194
+ namespace: NAMESPACE,
195
+ package: PACKAGE_NAME,
196
+ remove: [ENTRYPOINT]
197
+ });
198
+ }
103
199
 
104
- return intents;
200
+ return { dependencies: [...dependencies], ...intents };
105
201
  }
106
202
  };