@qq99/eslint-plugin-solid 0.18.0-no-reactive-read-in-effect-callback.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +289 -0
  3. package/dist/chunk-4UZNJ5MS.mjs +32 -0
  4. package/dist/chunk-4UZNJ5MS.mjs.map +1 -0
  5. package/dist/chunk-EBU4C5NV.mjs +77 -0
  6. package/dist/chunk-EBU4C5NV.mjs.map +1 -0
  7. package/dist/chunk-LE6SW2M3.mjs +6231 -0
  8. package/dist/chunk-LE6SW2M3.mjs.map +1 -0
  9. package/dist/chunk-MHDQJMLE.mjs +39 -0
  10. package/dist/chunk-MHDQJMLE.mjs.map +1 -0
  11. package/dist/configs/recommended.d.mts +109 -0
  12. package/dist/configs/recommended.d.ts +109 -0
  13. package/dist/configs/recommended.js +5877 -0
  14. package/dist/configs/recommended.js.map +1 -0
  15. package/dist/configs/recommended.mjs +5 -0
  16. package/dist/configs/recommended.mjs.map +1 -0
  17. package/dist/configs/typescript.d.mts +103 -0
  18. package/dist/configs/typescript.d.ts +103 -0
  19. package/dist/configs/typescript.js +6246 -0
  20. package/dist/configs/typescript.js.map +1 -0
  21. package/dist/configs/typescript.mjs +6 -0
  22. package/dist/configs/typescript.mjs.map +1 -0
  23. package/dist/configs/v2-strict.d.mts +130 -0
  24. package/dist/configs/v2-strict.d.ts +130 -0
  25. package/dist/configs/v2-strict.js +6317 -0
  26. package/dist/configs/v2-strict.js.map +1 -0
  27. package/dist/configs/v2-strict.mjs +7 -0
  28. package/dist/configs/v2-strict.mjs.map +1 -0
  29. package/dist/configs/v2.d.mts +127 -0
  30. package/dist/configs/v2.d.ts +127 -0
  31. package/dist/configs/v2.js +6289 -0
  32. package/dist/configs/v2.js.map +1 -0
  33. package/dist/configs/v2.mjs +6 -0
  34. package/dist/configs/v2.mjs.map +1 -0
  35. package/dist/index.d.mts +740 -0
  36. package/dist/index.d.ts +740 -0
  37. package/dist/index.js +6365 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/index.mjs +42 -0
  40. package/dist/index.mjs.map +1 -0
  41. package/package.json +109 -0
  42. package/src/compat.ts +53 -0
  43. package/src/configs/recommended.ts +48 -0
  44. package/src/configs/typescript.ts +21 -0
  45. package/src/configs/v2-strict.ts +28 -0
  46. package/src/configs/v2.ts +68 -0
  47. package/src/deps.d.ts +1 -0
  48. package/src/index.ts +29 -0
  49. package/src/plugin.ts +92 -0
  50. package/src/rules/components-return-once.ts +209 -0
  51. package/src/rules/event-handlers.ts +397 -0
  52. package/src/rules/imports.ts +338 -0
  53. package/src/rules/jsx-no-duplicate-props.ts +110 -0
  54. package/src/rules/jsx-no-script-url.ts +61 -0
  55. package/src/rules/jsx-no-undef.ts +230 -0
  56. package/src/rules/jsx-uses-vars.ts +66 -0
  57. package/src/rules/no-accessor-as-prop.ts +147 -0
  58. package/src/rules/no-array-handlers.ts +57 -0
  59. package/src/rules/no-boolean-enumerated-attribute.ts +209 -0
  60. package/src/rules/no-browser-globals-in-server-function.ts +108 -0
  61. package/src/rules/no-destructure.ts +245 -0
  62. package/src/rules/no-innerhtml.ts +143 -0
  63. package/src/rules/no-invalid-server-capture.ts +104 -0
  64. package/src/rules/no-module-scope-reactive-primitive.ts +69 -0
  65. package/src/rules/no-proxy-apis.ts +96 -0
  66. package/src/rules/no-react-deps.ts +68 -0
  67. package/src/rules/no-react-specific-props.ts +60 -0
  68. package/src/rules/no-reactive-read-in-effect-callback.ts +570 -0
  69. package/src/rules/no-restated-default-options.ts +96 -0
  70. package/src/rules/no-single-arg-create-effect.ts +56 -0
  71. package/src/rules/no-store-mutation-outside-setter.ts +137 -0
  72. package/src/rules/no-unknown-namespaces.ts +140 -0
  73. package/src/rules/no-unused-signal.ts +119 -0
  74. package/src/rules/no-write-in-pure-computation.ts +231 -0
  75. package/src/rules/prefer-classlist.ts +93 -0
  76. package/src/rules/prefer-for.ts +93 -0
  77. package/src/rules/prefer-onSettled-for-side-effects.ts +101 -0
  78. package/src/rules/prefer-show.ts +102 -0
  79. package/src/rules/prefer-structured-class.ts +117 -0
  80. package/src/rules/reactivity.ts +1730 -0
  81. package/src/rules/removed-api.ts +212 -0
  82. package/src/rules/require-async-server-function.ts +122 -0
  83. package/src/rules/self-closing-comp.ts +150 -0
  84. package/src/rules/style-prop.ts +140 -0
  85. package/src/rules/valid-use-server.ts +192 -0
  86. package/src/rules/validate-jsx-nesting.ts +1 -0
  87. package/src/utils/reactive-imports.ts +382 -0
  88. package/src/utils/reactive-types.ts +272 -0
  89. package/src/utils/reactive-values.ts +162 -0
  90. package/src/utils.ts +442 -0
@@ -0,0 +1,1730 @@
1
+ /**
2
+ * File overview here, scroll to bottom.
3
+ * @link https://github.com/solidjs-community/eslint-plugin-solid/blob/main/docs/reactivity.md
4
+ */
5
+
6
+ import { TSESTree as T, TSESLint, ESLintUtils, ASTUtils } from "@typescript-eslint/utils";
7
+ import { traverse } from "estraverse";
8
+ import {
9
+ findParent,
10
+ findInScope,
11
+ isPropsByName,
12
+ FunctionNode,
13
+ isFunctionNode,
14
+ ProgramOrFunctionNode,
15
+ isProgramOrFunctionNode,
16
+ getSolidSourceRegex,
17
+ trackImports,
18
+ isDOMElementName,
19
+ ignoreTransparentWrappers,
20
+ getFunctionName,
21
+ isJSXElementOrFragment,
22
+ isSolidV2,
23
+ trace,
24
+ createNameMatcher,
25
+ } from "../utils";
26
+ import { findVariable, getScope, CompatContext, getSourceCode } from "../compat";
27
+
28
+ const { getFunctionHeadLocation } = ASTUtils;
29
+ const createRule = ESLintUtils.RuleCreator.withoutDocs;
30
+
31
+ type Variable = TSESLint.Scope.Variable;
32
+ type Reference = TSESLint.Scope.Reference;
33
+
34
+ interface ReactiveVariable {
35
+ /**
36
+ * The reactive variable references we're concerned with (i.e. not init).
37
+ * References are removed after they are analyzed.
38
+ */
39
+ references: Array<Reference>;
40
+ /**
41
+ * The function node in which the reactive variable was declared, or for a
42
+ * derived signal (function), the deepest function node that declares a
43
+ * referenced signal.
44
+ */
45
+ declarationScope: ProgramOrFunctionNode;
46
+ /**
47
+ * The reactive variable. Not used directly, only needed for identification
48
+ * in pushUniqueDerivedSignal.
49
+ */
50
+ variable: Variable;
51
+ }
52
+
53
+ interface TrackedScope {
54
+ /**
55
+ * The root node, usually a function or JSX expression container, to allow
56
+ * reactive variables under.
57
+ */
58
+ node: T.Node;
59
+ /**
60
+ * The reactive variable should be one of these types:
61
+ * - "function": synchronous function or signal variable
62
+ * - "called-function": synchronous or asynchronous function like a timer or
63
+ * event handler that isn't really a tracked scope but allows reactivity
64
+ * - "expression": some value containing reactivity somewhere
65
+ */
66
+ expect: "function" | "called-function" | "expression";
67
+ }
68
+
69
+ class ScopeStackItem {
70
+ /** the node for the current scope, or program if global scope */
71
+ node: ProgramOrFunctionNode;
72
+ /**
73
+ * nodes whose descendants in the current scope are allowed to be reactive.
74
+ * JSXExpressionContainers can be any expression containing reactivity, while
75
+ * function nodes/identifiers are typically arguments to solid-js primitives
76
+ * and should match a tracked scope exactly.
77
+ */
78
+ trackedScopes: Array<TrackedScope> = [];
79
+ /** nameless functions with reactivity, should exactly match a tracked scope */
80
+ unnamedDerivedSignals = new Set<FunctionNode>();
81
+ /** switched to true by time of :exit if JSX is detected in the current scope */
82
+ hasJSX = false;
83
+
84
+ constructor(node: ProgramOrFunctionNode) {
85
+ this.node = node;
86
+ }
87
+ }
88
+
89
+ class ScopeStack extends Array<ScopeStackItem> {
90
+ currentScope = () => this[this.length - 1];
91
+ parentScope = () => this[this.length - 2];
92
+
93
+ /** Add references to a signal, memo, derived signal, etc. */
94
+ pushSignal(
95
+ variable: Variable,
96
+ declarationScope: ProgramOrFunctionNode = this.currentScope().node
97
+ ) {
98
+ this.signals.push({
99
+ references: variable.references.filter((reference) => !reference.init),
100
+ variable,
101
+ declarationScope,
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Add references to a signal, merging with existing references if the
107
+ * variable is the same. Derived signals are special; they don't use the
108
+ * declaration scope of the function, but rather the minimum declaration scope
109
+ * of any signals they contain.
110
+ */
111
+ pushUniqueSignal(variable: Variable, declarationScope: ProgramOrFunctionNode) {
112
+ const foundSignal = this.signals.find((s) => s.variable === variable);
113
+ if (!foundSignal) {
114
+ this.pushSignal(variable, declarationScope);
115
+ } else {
116
+ foundSignal.declarationScope = this.findDeepestDeclarationScope(
117
+ foundSignal.declarationScope,
118
+ declarationScope
119
+ );
120
+ }
121
+ }
122
+
123
+ /** Add references to a props or store. */
124
+ pushProps(
125
+ variable: Variable,
126
+ declarationScope: ProgramOrFunctionNode = this.currentScope().node
127
+ ) {
128
+ this.props.push({
129
+ references: variable.references.filter((reference) => !reference.init),
130
+ variable,
131
+ declarationScope,
132
+ });
133
+ }
134
+
135
+ /** Function callbacks that run synchronously and don't create a new scope. */
136
+ syncCallbacks = new Set<FunctionNode>();
137
+
138
+ /**
139
+ * Iterate through and remove the signal references in the current scope.
140
+ * That way, the next Scope up can safely check for references in its scope.
141
+ */
142
+ *consumeSignalReferencesInScope() {
143
+ yield* this.consumeReferencesInScope(this.signals);
144
+ this.signals = this.signals.filter((variable) => variable.references.length !== 0);
145
+ }
146
+
147
+ /** Iterate through and remove the props references in the current scope. */
148
+ *consumePropsReferencesInScope() {
149
+ yield* this.consumeReferencesInScope(this.props);
150
+ this.props = this.props.filter((variable) => variable.references.length !== 0);
151
+ }
152
+
153
+ private *consumeReferencesInScope(
154
+ variables: Array<ReactiveVariable>
155
+ ): Iterable<{ reference: Reference; declarationScope: ProgramOrFunctionNode }> {
156
+ for (const variable of variables) {
157
+ const { references } = variable;
158
+ const inScope: Array<Reference> = [],
159
+ notInScope: Array<Reference> = [];
160
+ references.forEach((reference) => {
161
+ if (this.isReferenceInCurrentScope(reference)) {
162
+ inScope.push(reference);
163
+ } else {
164
+ notInScope.push(reference);
165
+ }
166
+ });
167
+ yield* inScope.map((reference) => ({
168
+ reference,
169
+ declarationScope: variable.declarationScope,
170
+ }));
171
+ // I don't think this is needed! Just a perf optimization
172
+ variable.references = notInScope;
173
+ }
174
+ }
175
+
176
+ /** Returns the function node deepest in the tree. Assumes a === b, a is inside b, or b is inside a. */
177
+ private findDeepestDeclarationScope = (
178
+ a: ProgramOrFunctionNode,
179
+ b: ProgramOrFunctionNode
180
+ ): ProgramOrFunctionNode => {
181
+ if (a === b) return a;
182
+ for (let i = this.length - 1; i >= 0; i -= 1) {
183
+ const { node } = this[i];
184
+ if (a === node || b === node) {
185
+ return node;
186
+ }
187
+ }
188
+ throw new Error("This should never happen");
189
+ };
190
+
191
+ /**
192
+ * Returns true if the reference is in the current scope, handling sync
193
+ * callbacks. Must be called on the :exit pass only.
194
+ */
195
+ private isReferenceInCurrentScope(reference: Reference) {
196
+ let parentFunction = findParent(reference.identifier, isProgramOrFunctionNode);
197
+ while (isFunctionNode(parentFunction) && this.syncCallbacks.has(parentFunction)) {
198
+ parentFunction = findParent(parentFunction, isProgramOrFunctionNode);
199
+ }
200
+ return parentFunction === this.currentScope().node;
201
+ }
202
+
203
+ /** variable references to be treated as signals, memos, derived signals, etc. */
204
+ private signals: Array<ReactiveVariable> = [];
205
+ /** variables references to be treated as props (or stores) */
206
+ private props: Array<ReactiveVariable> = [];
207
+ }
208
+
209
+ const getNthDestructuredVar = (id: T.Node, n: number, context: CompatContext): Variable | null => {
210
+ if (id?.type === "ArrayPattern") {
211
+ const el = id.elements[n];
212
+ if (el?.type === "Identifier") {
213
+ return findVariable(context, el);
214
+ }
215
+ } else if (id?.type === "ObjectPattern") {
216
+ // {0: a, '1': b, 2: c, ...rest}
217
+ const el = id.properties.find((p): p is T.Property => {
218
+ if (p.type !== "Property") return false;
219
+ if (p.key.type !== "Literal") return false;
220
+
221
+ const key = p.key;
222
+ return key.value === n || (typeof key.value === "string" && key.value === String(n));
223
+ });
224
+
225
+ if (el?.type === "Property" && el.value.type === "Identifier") {
226
+ return findVariable(context, el.value);
227
+ }
228
+ }
229
+ return null;
230
+ };
231
+
232
+ const getReturnedVar = (id: T.Node, context: CompatContext): Variable | null => {
233
+ if (id.type === "Identifier") {
234
+ return findVariable(context, id);
235
+ }
236
+ return null;
237
+ };
238
+
239
+ type MessageIds =
240
+ | "noWrite"
241
+ | "untrackedReactive"
242
+ | "expectedFunctionGotExpression"
243
+ | "badSignal"
244
+ | "badUnnamedDerivedSignal"
245
+ | "shouldDestructure"
246
+ | "shouldAssign"
247
+ | "noAsyncTrackedScope"
248
+ | "readAfterAwait"
249
+ | "staleCapture"
250
+ | "providerValue";
251
+ type Options = [{ customReactiveFunctions: string[] }];
252
+
253
+ export default createRule<Options, MessageIds>({
254
+ meta: {
255
+ type: "problem",
256
+ docs: {
257
+ description:
258
+ "Enforce that reactivity (props, signals, memos, etc.) is properly used, so changes in those values will be tracked and update the view as expected.",
259
+ url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/reactivity.md",
260
+ },
261
+ schema: [
262
+ {
263
+ type: "object",
264
+ properties: {
265
+ customReactiveFunctions: {
266
+ description:
267
+ "List of function names to consider as reactive functions (allow signals to be safely passed as arguments). Supports exact names, '*' wildcards ('use*Store'), and regexes given as '/pattern/' strings. In addition, any create* or use* functions are automatically included.",
268
+ type: "array",
269
+ items: {
270
+ type: "string",
271
+ },
272
+ default: [],
273
+ },
274
+ },
275
+ additionalProperties: false,
276
+ },
277
+ ],
278
+ messages: {
279
+ noWrite: "The reactive variable '{{name}}' should not be reassigned or altered directly.",
280
+ untrackedReactive:
281
+ "The reactive variable '{{name}}' should be used within JSX, a tracked scope (like createEffect), or inside an event handler function, or else changes will be ignored.",
282
+ expectedFunctionGotExpression:
283
+ "The reactive variable '{{name}}' should be wrapped in a function for reactivity. This includes event handler bindings on native elements, which are not reactive like other JSX props.",
284
+ badSignal:
285
+ "The reactive variable '{{name}}' should be called as a function when used in {{where}}.",
286
+ badUnnamedDerivedSignal:
287
+ "This function should be passed to a tracked scope (like createEffect) or an event handler because it contains reactivity, or else changes will be ignored.",
288
+ shouldDestructure:
289
+ "For proper analysis, array destructuring should be used to capture the {{nth}}result of this function call.",
290
+ shouldAssign:
291
+ "For proper analysis, a variable should be used to capture the result of this function call.",
292
+ noAsyncTrackedScope:
293
+ "This tracked scope should not be async. Solid's reactivity only tracks synchronously.",
294
+ readAfterAwait:
295
+ "The reactive variable '{{name}}' is read after this computation suspends (at an 'await' or 'yield'), so changes to it won't be tracked. Read it before the first suspension point and store the result in a variable.",
296
+ staleCapture:
297
+ "'{{captured}}' captures the value of the reactive variable '{{name}}' at setup, but a returned function reads the capture later — it will never update. Call '{{name}}' inside the returned function instead, or prefix '{{captured}}' with 'initial'/'default'/'static' if a one-time snapshot is intended.",
298
+ providerValue:
299
+ "A context provider reads its 'value' prop only once, when it is created, so the reactive variable '{{name}}' will not stay up to date here. Pass the signal, memo, or store itself (or an object containing them) instead of reading it in JSX.",
300
+ },
301
+ },
302
+ defaultOptions: [
303
+ {
304
+ customReactiveFunctions: [],
305
+ },
306
+ ],
307
+ create(context, [options]) {
308
+ const warnShouldDestructure = (node: T.Node, nth?: string) =>
309
+ context.report({
310
+ node,
311
+ messageId: "shouldDestructure",
312
+ data: nth ? { nth: nth + " " } : undefined,
313
+ });
314
+ const warnShouldAssign = (node: T.Node) => context.report({ node, messageId: "shouldAssign" });
315
+
316
+ const sourceCode = getSourceCode(context);
317
+
318
+ /** Represents the lexical function stack and relevant information for each function */
319
+ const scopeStack = new ScopeStack();
320
+ const { currentScope, parentScope } = scopeStack;
321
+
322
+ /** Tracks imports from 'solid-js', handling aliases. */
323
+ const { matchImport, handleImportDeclaration } = trackImports(getSolidSourceRegex(context));
324
+
325
+ /** Solid 2.0 mode (settings.solid.version >= 2). */
326
+ const v2 = isSolidV2(context);
327
+
328
+ /**
329
+ * `customReactiveFunctions` entries may be exact names, glob-ish patterns using `*`
330
+ * wildcards, or regexes written as "/pattern/" strings.
331
+ */
332
+ const matchesCustomReactive = createNameMatcher(options.customReactiveFunctions);
333
+
334
+ /**
335
+ * JSXExpressionContainers for `value={...}` props on context providers. Providers read
336
+ * `props.value` once, untracked, when created (in both Solid 1.x and 2.0), so reactive
337
+ * expressions here are frozen at their initial value. Recorded so reads inside them get
338
+ * a specific error message instead of the generic untracked-reactivity one.
339
+ */
340
+ const providerValueContainers = new WeakSet<T.Node>();
341
+
342
+ /** Returns whether a name resolves to the result of a `createContext()` call in scope. */
343
+ const isCreatedContext = (name: string, refNode: T.Node): boolean => {
344
+ const variable = ASTUtils.findVariable(getScope(context, refNode), name);
345
+ const def = variable?.defs[0];
346
+ return Boolean(
347
+ def &&
348
+ def.node.type === "VariableDeclarator" &&
349
+ def.node.init?.type === "CallExpression" &&
350
+ def.node.init.callee.type === "Identifier" &&
351
+ matchImport("createContext", def.node.init.callee.name)
352
+ );
353
+ };
354
+
355
+ /**
356
+ * Detects JSX element names that are context providers: `<SomeContext.Provider>` and
357
+ * `<SomeProvider>` by convention, plus any name resolving to a `createContext()` call —
358
+ * in Solid 2.0 the context object is used directly as the provider (`<MyContext value={}>`).
359
+ */
360
+ const isProviderElementName = (name: T.JSXTagNameExpression): boolean => {
361
+ if (name.type === "JSXIdentifier") {
362
+ return name.name.endsWith("Provider") || isCreatedContext(name.name, name);
363
+ }
364
+ if (name.type === "JSXMemberExpression") {
365
+ return name.property.name === "Provider";
366
+ }
367
+ return false;
368
+ };
369
+
370
+ /**
371
+ * Async/generator functions that are tracked scopes for Solid 2.0 async computations
372
+ * (async createMemo, function-form createSignal/createStore, etc.). Only reads before
373
+ * the first suspension point (`await`/`yield`) are tracked; later reads are reported.
374
+ */
375
+ const asyncTrackedScopes = new Set<FunctionNode>();
376
+ const suspensionTaintCache = new Map<FunctionNode, number | null>();
377
+ /**
378
+ * If `fn` is an async computation, returns the source position after which reactive
379
+ * reads are no longer tracked, or null if it never suspends. When the first suspension
380
+ * point is inside a loop, the loop is tainted too, since later iterations read after
381
+ * the previous iteration's suspension.
382
+ */
383
+ const getSuspensionTaint = (fn: ProgramOrFunctionNode): number | null => {
384
+ if (!isFunctionNode(fn) || !asyncTrackedScopes.has(fn)) return null;
385
+ const cached = suspensionTaintCache.get(fn);
386
+ if (cached !== undefined) return cached;
387
+ let suspension: T.Node | undefined;
388
+ traverse(fn.body as never, {
389
+ enter(cn) {
390
+ const node = cn as T.Node;
391
+ if (node !== fn.body && isFunctionNode(node)) {
392
+ this.skip(); // nested functions suspend independently
393
+ } else if (
394
+ node.type === "AwaitExpression" ||
395
+ node.type === "YieldExpression" ||
396
+ (node.type === "ForOfStatement" && node.await)
397
+ ) {
398
+ suspension = node;
399
+ this.break();
400
+ }
401
+ },
402
+ fallback: "iteration",
403
+ });
404
+ let taint: number | null = null;
405
+ if (suspension) {
406
+ // the operands of the first suspension point are still evaluated synchronously
407
+ taint =
408
+ suspension.type === "ForOfStatement" ? suspension.body.range[0] : suspension.range[1];
409
+ let ancestor: T.Node | undefined = suspension.parent;
410
+ while (ancestor && ancestor !== fn) {
411
+ if (
412
+ ancestor.type === "ForStatement" ||
413
+ ancestor.type === "WhileStatement" ||
414
+ ancestor.type === "DoWhileStatement"
415
+ ) {
416
+ taint = ancestor.range[0];
417
+ } else if (ancestor.type === "ForOfStatement" || ancestor.type === "ForInStatement") {
418
+ // the iterated expression is only evaluated once, before the first suspension
419
+ taint = ancestor.body.range[0];
420
+ }
421
+ ancestor = ancestor.parent;
422
+ }
423
+ }
424
+ suspensionTaintCache.set(fn, taint);
425
+ return taint;
426
+ };
427
+
428
+ /** Workaround for #61 */
429
+ const markPropsOnCondition = (node: FunctionNode, cb: (props: T.Identifier) => boolean) => {
430
+ if (
431
+ node.params.length === 1 &&
432
+ node.params[0].type === "Identifier" &&
433
+ node.parent?.type !== "JSXExpressionContainer" && // "render props" aren't components
434
+ node.parent?.type !== "TemplateLiteral" && // inline functions in tagged template literals aren't components
435
+ cb(node.params[0])
436
+ ) {
437
+ // This function is a component, consider its parameter a props
438
+ const propsParam = findVariable(context, node.params[0]);
439
+ if (propsParam) {
440
+ scopeStack.pushProps(propsParam, node);
441
+ }
442
+ }
443
+ };
444
+
445
+ /** Populates the function stack. */
446
+ const onFunctionEnter = (node: ProgramOrFunctionNode) => {
447
+ if (isFunctionNode(node)) {
448
+ if (scopeStack.syncCallbacks.has(node)) {
449
+ // Ignore sync callbacks like Array#forEach and certain Solid primitives
450
+ return;
451
+ }
452
+ markPropsOnCondition(node, (props) => isPropsByName(props.name));
453
+ }
454
+ scopeStack.push(new ScopeStackItem(node));
455
+ };
456
+
457
+ /** Returns whether a node falls under a tracked scope in the current function scope */
458
+ const matchTrackedScope = (trackedScope: TrackedScope, node: T.Node): boolean => {
459
+ switch (trackedScope.expect) {
460
+ case "function":
461
+ case "called-function":
462
+ return node === trackedScope.node;
463
+ case "expression":
464
+ return Boolean(
465
+ findInScope(node, currentScope().node, (node) => node === trackedScope.node)
466
+ );
467
+ }
468
+ };
469
+
470
+ /**
471
+ * Returns true when `fn` is directly returned by the enclosing function:
472
+ * either the argument of a `return` statement or the whole body of an
473
+ * arrow function. Deliberately strict — a function that merely appears
474
+ * somewhere inside a returned expression (e.g. a runWithOwner callback
475
+ * in a returned call) is not "returned" for our purposes.
476
+ */
477
+ const isDirectlyReturned = (fn: FunctionNode): boolean =>
478
+ fn.parent?.type === "ReturnStatement" ||
479
+ (fn.parent?.type === "ArrowFunctionExpression" && fn.parent.body === fn);
480
+
481
+ /**
482
+ * Detects the stale-capture footgun: a signal called at a function's
483
+ * setup level, its result captured in a variable, and that variable read
484
+ * by a function the enclosing function *returns*. The returned function
485
+ * pretends to be live but reads a value frozen at setup. Reports and
486
+ * returns true when found.
487
+ */
488
+ const checkStaleCapture = (identifier: T.Identifier): boolean => {
489
+ const call = identifier.parent;
490
+ if (call?.type !== "CallExpression" || call.callee !== identifier) return false;
491
+ // Find the VariableDeclarator whose init contains this call, walking up
492
+ // through intermediate expressions (`const t = items().length`) but
493
+ // never through a statement or another function.
494
+ let child: T.Node = call;
495
+ while (
496
+ child.parent &&
497
+ child.parent.type !== "VariableDeclarator" &&
498
+ !isFunctionNode(child.parent) &&
499
+ !child.parent.type.endsWith("Statement")
500
+ ) {
501
+ child = child.parent;
502
+ }
503
+ const declarator = child.parent;
504
+ if (declarator?.type !== "VariableDeclarator" || declarator.init !== child) return false;
505
+
506
+ const currentScopeNode = currentScope().node;
507
+ const capturedVars = sourceCode.scopeManager?.getDeclaredVariables(declarator) ?? [];
508
+ for (const captured of capturedVars) {
509
+ // The initial/default/static naming convention opts into a one-time
510
+ // snapshot, same as for props reads.
511
+ if (/^(?:initial|default|static[A-Z])/.test(captured.name)) continue;
512
+ for (const reference of captured.references) {
513
+ if (reference.init) continue;
514
+ if (isCapturedInReturnedFunction(reference.identifier, currentScopeNode)) {
515
+ context.report({
516
+ node: call,
517
+ messageId: "staleCapture",
518
+ data: { name: identifier.name, captured: captured.name },
519
+ });
520
+ return true;
521
+ }
522
+ }
523
+ }
524
+ return false;
525
+ };
526
+
527
+ /**
528
+ * Returns true when `node` sits inside a nested function that escapes the
529
+ * `boundary` function through a `return` statement (including functions
530
+ * embedded in returned JSX or objects).
531
+ */
532
+ const isCapturedInReturnedFunction = (
533
+ node: T.Node,
534
+ boundary: ProgramOrFunctionNode
535
+ ): boolean => {
536
+ // Find the outermost function strictly inside `boundary` containing `node`.
537
+ let outermost: FunctionNode | null = null;
538
+ let cursor: T.Node | null = node;
539
+ while (cursor && cursor !== boundary) {
540
+ if (isFunctionNode(cursor)) outermost = cursor;
541
+ cursor = cursor.parent ?? null;
542
+ }
543
+ if (!cursor || !outermost) return false; // not under boundary, or not captured by a closure
544
+ // Walk from that function to `boundary`; only non-function nodes remain
545
+ // in between, so a ReturnStatement found here belongs to `boundary`.
546
+ let child: T.Node = outermost;
547
+ let parent: T.Node | null = child.parent ?? null;
548
+ while (parent && child !== boundary) {
549
+ if (parent.type === "ReturnStatement") return true;
550
+ // A function passed as a call argument doesn't escape through a
551
+ // `return` below it — only the call's *result* does. Synchronous
552
+ // callbacks (`return items.filter((item) => item.includes(q))`) run
553
+ // during the computation, where the capture is fresh (#223). The
554
+ // same goes for a called function itself (IIFEs).
555
+ if (parent.type === "CallExpression" || parent.type === "NewExpression") return false;
556
+ if (isFunctionNode(parent)) {
557
+ return (
558
+ parent === boundary &&
559
+ parent.type === "ArrowFunctionExpression" &&
560
+ parent.body === child
561
+ );
562
+ }
563
+ child = parent;
564
+ parent = parent.parent ?? null;
565
+ }
566
+ return false;
567
+ };
568
+
569
+ /** Inspects a specific reference of a reactive variable for correct handling. */
570
+ const handleTrackedScopes = (
571
+ identifier: T.Identifier,
572
+ declarationScope: ProgramOrFunctionNode
573
+ ) => {
574
+ const currentScopeNode = currentScope().node;
575
+ // Check if the call falls outside any tracked scopes in the current scope
576
+ if (
577
+ !currentScope().trackedScopes.find((trackedScope) =>
578
+ matchTrackedScope(trackedScope, identifier)
579
+ )
580
+ ) {
581
+ const matchedExpression = currentScope().trackedScopes.find((trackedScope) =>
582
+ matchTrackedScope({ ...trackedScope, expect: "expression" }, identifier)
583
+ );
584
+ if (declarationScope === currentScopeNode) {
585
+ // If the reactivity is not contained in a tracked scope, and any of
586
+ // the reactive variables were declared in the current scope, then we
587
+ // report them. When the reference is to an object in a
588
+ // MemberExpression (props/store) or a function call (signal), report
589
+ // that, otherwise the identifier.
590
+ let parentMemberExpression: T.MemberExpression | null = null;
591
+ if (identifier.parent?.type === "MemberExpression") {
592
+ parentMemberExpression = identifier.parent;
593
+ while (parentMemberExpression!.parent?.type === "MemberExpression") {
594
+ parentMemberExpression = parentMemberExpression!.parent;
595
+ }
596
+ }
597
+ const parentCallExpression =
598
+ identifier.parent?.type === "CallExpression" ? identifier.parent : null;
599
+ // Reads inside a context provider's `value={...}` get a specific message; the
600
+ // value is read once, untracked, when the provider is created.
601
+ const inProviderValue = Boolean(
602
+ findParent(identifier, (n) => providerValueContainers.has(n))
603
+ );
604
+ context.report({
605
+ node: parentMemberExpression ?? parentCallExpression ?? identifier,
606
+ messageId: inProviderValue
607
+ ? "providerValue"
608
+ : matchedExpression
609
+ ? "expectedFunctionGotExpression"
610
+ : "untrackedReactive",
611
+ data: {
612
+ name: parentMemberExpression
613
+ ? sourceCode.getText(parentMemberExpression)
614
+ : identifier.name,
615
+ },
616
+ });
617
+ } else {
618
+ // If all of the reactive variables were declared above the current
619
+ // function scope, then the entire function becomes reactive with the
620
+ // deepest declaration scope of the reactive variables it contains.
621
+ // Let the next onFunctionExit up handle it.
622
+ if (!parentScope() || !isFunctionNode(currentScopeNode)) {
623
+ throw new Error("this shouldn't happen!");
624
+ }
625
+
626
+ // Signal called at this function's setup level with its value
627
+ // captured by a variable that a *returned* function reads: the
628
+ // returned function will read a stale value forever. Report here —
629
+ // treating the enclosing function as a derived signal (below) is
630
+ // still correct, but no call-site discipline can fix the capture.
631
+ checkStaleCapture(identifier);
632
+
633
+ // If the current function doesn't have an associated variable, that's
634
+ // fine, it's being used inline (i.e. anonymous arrow function). For
635
+ // this to be okay, the arrow function has to be the same node as one
636
+ // of the tracked scopes, as we can't easily find references.
637
+ const pushUnnamedDerivedSignal = () =>
638
+ (parentScope().unnamedDerivedSignals ??= new Set()).add(currentScopeNode);
639
+
640
+ if (isDirectlyReturned(currentScopeNode)) {
641
+ // A function directly returned by its enclosing function is an
642
+ // accessor handed to the caller — the custom-primitive contract.
643
+ // The caller decides whether it lands in a tracked scope; there is
644
+ // nowhere in this file for it to match a tracked scope, so
645
+ // requiring one only punishes the idiomatic hook shape
646
+ // (`return () => signal()`). Do nothing.
647
+ } else if (currentScopeNode.type === "FunctionDeclaration") {
648
+ // get variable representing function, function node only defines one variable
649
+ const functionVariable: Variable | undefined =
650
+ sourceCode.scopeManager?.getDeclaredVariables(currentScopeNode)?.[0];
651
+ if (functionVariable) {
652
+ scopeStack.pushUniqueSignal(
653
+ functionVariable,
654
+ declarationScope // use declaration scope of a signal contained in this function
655
+ );
656
+ } else {
657
+ pushUnnamedDerivedSignal();
658
+ }
659
+ } else if (currentScopeNode.parent?.type === "VariableDeclarator") {
660
+ const declarator = currentScopeNode.parent;
661
+ // for nameless or arrow function expressions, use the declared variable it's assigned to
662
+ const functionVariable = sourceCode.scopeManager?.getDeclaredVariables(declarator)?.[0];
663
+ if (functionVariable) {
664
+ // use declaration scope of a signal contained in this scope, not the function itself
665
+ scopeStack.pushUniqueSignal(functionVariable, declarationScope);
666
+ } else {
667
+ pushUnnamedDerivedSignal();
668
+ }
669
+ } else if (currentScopeNode.parent?.type === "Property") {
670
+ // todo make this a unique props or something--for now, just ignore (unsafe)
671
+ } else {
672
+ pushUnnamedDerivedSignal();
673
+ }
674
+ }
675
+ }
676
+ };
677
+
678
+ /** Performs all analysis and reporting. */
679
+ const onFunctionExit = (currentScopeNode: ProgramOrFunctionNode) => {
680
+ // If this function is a component, add its props as a reactive variable
681
+ if (isFunctionNode(currentScopeNode)) {
682
+ markPropsOnCondition(currentScopeNode, (props) => {
683
+ if (
684
+ !isPropsByName(props.name) && // already added in markPropsOnEnter
685
+ currentScope().hasJSX
686
+ ) {
687
+ const functionName = getFunctionName(currentScopeNode);
688
+ // begins with lowercase === not component
689
+ if (functionName && !/^[a-z]/.test(functionName)) return true;
690
+ }
691
+ return false;
692
+ });
693
+ }
694
+
695
+ // Ignore sync callbacks like Array#forEach and certain Solid primitives.
696
+ // In this case only, currentScopeNode !== currentScope().node, but we're
697
+ // returning early so it doesn't matter.
698
+ if (isFunctionNode(currentScopeNode) && scopeStack.syncCallbacks.has(currentScopeNode)) {
699
+ return;
700
+ }
701
+
702
+ // If this scope is an async computation (Solid 2.0), reactive reads positioned
703
+ // after its first suspension point resume outside the tracking window.
704
+ const suspensionTaint = getSuspensionTaint(currentScopeNode);
705
+ const isReadAfterSuspension = (identifier: T.Node): boolean =>
706
+ suspensionTaint != null && identifier.range[0] >= suspensionTaint;
707
+
708
+ // Iterate through all usages of (derived) signals in the current scope
709
+ for (const { reference, declarationScope } of scopeStack.consumeSignalReferencesInScope()) {
710
+ const identifier = reference.identifier;
711
+ if (reference.isWrite()) {
712
+ // don't allow reassigning signals
713
+ context.report({
714
+ node: identifier,
715
+ messageId: "noWrite",
716
+ data: {
717
+ name: identifier.name,
718
+ },
719
+ });
720
+ } else if (identifier.type === "Identifier") {
721
+ const reportBadSignal = (where: string) =>
722
+ context.report({
723
+ node: identifier,
724
+ messageId: "badSignal",
725
+ data: { name: identifier.name, where },
726
+ });
727
+ if (
728
+ // This allows both calling a signal and calling a function with a signal.
729
+ identifier.parent?.type === "CallExpression" ||
730
+ // Also allow the case where we pass an array of signals, such as in a custom hook
731
+ (identifier.parent?.type === "ArrayExpression" &&
732
+ identifier.parent.parent?.type === "CallExpression")
733
+ ) {
734
+ if (isReadAfterSuspension(identifier)) {
735
+ // In an async computation, reads after the first suspension point aren't
736
+ // tracked (and in Solid 2.0 may observe unpredictable state).
737
+ context.report({
738
+ node: identifier,
739
+ messageId: "readAfterAwait",
740
+ data: { name: identifier.name },
741
+ });
742
+ } else {
743
+ // This signal is getting called properly, analyze it.
744
+ handleTrackedScopes(identifier, declarationScope);
745
+ }
746
+ } else if (identifier.parent?.type === "TemplateLiteral") {
747
+ reportBadSignal("template literals");
748
+ } else if (
749
+ identifier.parent?.type === "BinaryExpression" &&
750
+ [
751
+ "<",
752
+ "<=",
753
+ ">",
754
+ ">=",
755
+ "<<",
756
+ ">>",
757
+ ">>>",
758
+ "+",
759
+ "-",
760
+ "*",
761
+ "/",
762
+ "%",
763
+ "**",
764
+ "|",
765
+ "^",
766
+ "&",
767
+ "in",
768
+ ].includes(identifier.parent.operator)
769
+ ) {
770
+ // We're in an arithmetic/comparison expression where using an uncalled signal wouldn't make sense
771
+ reportBadSignal("arithmetic or comparisons");
772
+ } else if (
773
+ identifier.parent?.type === "UnaryExpression" &&
774
+ ["-", "+", "~"].includes(identifier.parent.operator)
775
+ ) {
776
+ // We're in a unary expression where using an uncalled signal wouldn't make sense
777
+ reportBadSignal("unary expressions");
778
+ } else if (
779
+ identifier.parent?.type === "MemberExpression" &&
780
+ identifier.parent.computed &&
781
+ identifier.parent.property === identifier
782
+ ) {
783
+ // We're using an uncalled signal to index an object or array, which doesn't make sense
784
+ reportBadSignal("property accesses");
785
+ } else if (
786
+ identifier.parent?.type === "JSXExpressionContainer" &&
787
+ !currentScope().trackedScopes.find(
788
+ (trackedScope) =>
789
+ trackedScope.node === identifier &&
790
+ (trackedScope.expect === "function" || trackedScope.expect === "called-function")
791
+ )
792
+ ) {
793
+ // If the signal is in a JSXExpressionContainer that's also marked as a "function" or "called-function" tracked scope,
794
+ // let it be.
795
+ const elementOrAttribute = identifier.parent.parent;
796
+ if (
797
+ // The signal is not being called and is being used as a props.children, where calling
798
+ // the signal was the likely intent.
799
+ isJSXElementOrFragment(elementOrAttribute) ||
800
+ // We can't say for sure about user components, but we know for a fact that a signal
801
+ // should not be passed to a non-event handler DOM element attribute without calling it.
802
+ // In v2 mode this case is delegated to solid/no-accessor-as-prop so the same
803
+ // node never gets two reports.
804
+ (!v2 &&
805
+ elementOrAttribute?.type === "JSXAttribute" &&
806
+ elementOrAttribute.parent?.type === "JSXOpeningElement" &&
807
+ elementOrAttribute.parent.name.type === "JSXIdentifier" &&
808
+ isDOMElementName(elementOrAttribute.parent.name.name))
809
+ ) {
810
+ reportBadSignal("JSX");
811
+ }
812
+ }
813
+ }
814
+ // The signal is being read outside of a CallExpression. Since
815
+ // there's a lot of possibilities here and they're generally fine,
816
+ // do nothing.
817
+ }
818
+
819
+ // Do a similar thing with all usages of props in the current function
820
+ for (const { reference, declarationScope } of scopeStack.consumePropsReferencesInScope()) {
821
+ const identifier = reference.identifier;
822
+ if (reference.isWrite()) {
823
+ // don't allow reassigning props or stores
824
+ context.report({
825
+ node: identifier,
826
+ messageId: "noWrite",
827
+ data: {
828
+ name: identifier.name,
829
+ },
830
+ });
831
+ } else if (
832
+ identifier.parent?.type === "MemberExpression" &&
833
+ identifier.parent.object === identifier
834
+ ) {
835
+ const { parent } = identifier;
836
+ if (parent.parent?.type === "AssignmentExpression" && parent.parent.left === parent) {
837
+ // don't allow writing to props or stores directly
838
+ context.report({
839
+ node: identifier,
840
+ messageId: "noWrite",
841
+ data: {
842
+ name: identifier.name,
843
+ },
844
+ });
845
+ } else if (
846
+ parent.property.type === "Identifier" &&
847
+ /^(?:initial|default|static[A-Z])/.test(parent.property.name)
848
+ ) {
849
+ // We're using a prop with a name that starts with `initial` or
850
+ // `default`, like `props.initialCount`. We'll refrain from warning
851
+ // about untracked usages of these props, because the user has shown
852
+ // that they understand the consequences of using a reactive
853
+ // variable to initialize something else. Do nothing.
854
+ } else if (isReadAfterSuspension(identifier)) {
855
+ // In an async computation, reads after the first suspension point aren't
856
+ // tracked (and in Solid 2.0 may observe unpredictable state).
857
+ context.report({
858
+ node: identifier,
859
+ messageId: "readAfterAwait",
860
+ data: { name: identifier.name },
861
+ });
862
+ } else {
863
+ // The props are the object in a property read access, which
864
+ // should be under a tracked scope.
865
+ handleTrackedScopes(identifier, declarationScope);
866
+ }
867
+ } else if (
868
+ identifier.type === "Identifier" &&
869
+ (identifier.parent?.type === "AssignmentExpression" ||
870
+ identifier.parent?.type === "VariableDeclarator")
871
+ ) {
872
+ if (
873
+ identifier.parent.type === "AssignmentExpression" &&
874
+ identifier.parent.right === identifier &&
875
+ identifier.parent.left.type === "MemberExpression"
876
+ ) {
877
+ // `this.state = state` stores a *reference* to the proxy, not a
878
+ // snapshot: property reads through it are still reactive at read
879
+ // time. The alias escapes local analysis exactly like passing the
880
+ // proxy as a call argument, which is already permitted (#184).
881
+ // Do nothing.
882
+ } else {
883
+ // `... = props` is usually destructuring, which snapshots values instead of
884
+ // staying reactive. Inside a tracked scope (or a function passed to one) the
885
+ // destructuring re-runs on updates, so defer to the same analysis as reads.
886
+ handleTrackedScopes(identifier, declarationScope);
887
+ }
888
+ }
889
+ // The props are being read, but not in a MemberExpression. Since
890
+ // there's a lot of possibilities here and they're generally fine,
891
+ // do nothing.
892
+ }
893
+
894
+ // If there are any unnamed derived signals, they must match a tracked
895
+ // scope. Usually anonymous arrow function args to createEffect,
896
+ // createMemo, etc.
897
+ const { unnamedDerivedSignals } = currentScope();
898
+ if (unnamedDerivedSignals) {
899
+ for (const node of unnamedDerivedSignals) {
900
+ if (
901
+ !currentScope().trackedScopes.find((trackedScope) =>
902
+ matchTrackedScope(trackedScope, node)
903
+ )
904
+ ) {
905
+ context.report({
906
+ loc: getFunctionHeadLocation(node, sourceCode),
907
+ messageId: "badUnnamedDerivedSignal",
908
+ });
909
+ }
910
+ }
911
+ }
912
+
913
+ // Pop on exit
914
+ scopeStack.pop();
915
+ };
916
+
917
+ /*
918
+ * Sync array functions (forEach, map, reduce, reduceRight, flatMap),
919
+ * store update fn params (ex. setState("todos", (t) => [...t.slice(0, i()),
920
+ * ...t.slice(i() + 1)])), batch, onCleanup, and onError fn params, and
921
+ * maybe a few others don't actually create a new scope. That is, any
922
+ * signal/prop accesses in these functions act as if they happen in the
923
+ * enclosing function. Note that this means whether or not the enclosing
924
+ * function is a tracking scope applies to the fn param as well.
925
+ *
926
+ * Every time a sync callback is detected, we put that function node into a
927
+ * syncCallbacks Set<FunctionNode>. The detections must happen on the entry pass
928
+ * and when the function node has not yet been traversed. In onFunctionEnter, if
929
+ * the function node is in syncCallbacks, we don't push it onto the
930
+ * scopeStack. In onFunctionExit, if the function node is in syncCallbacks,
931
+ * we don't pop scopeStack.
932
+ */
933
+ const checkForSyncCallbacks = (node: T.CallExpression) => {
934
+ if (
935
+ node.arguments.length === 1 &&
936
+ isFunctionNode(node.arguments[0]) &&
937
+ !node.arguments[0].async
938
+ ) {
939
+ if (
940
+ node.callee.type === "Identifier" &&
941
+ matchImport(["batch", "produce", "flush"], node.callee.name)
942
+ ) {
943
+ // These Solid APIs take callbacks that run in the current scope
944
+ scopeStack.syncCallbacks.add(node.arguments[0]);
945
+ } else if (
946
+ node.callee.type === "MemberExpression" &&
947
+ !node.callee.computed &&
948
+ node.callee.object.type !== "ObjectExpression" &&
949
+ /^(?:forEach|map|flatMap|reduce|reduceRight|find|findIndex|filter|every|some)$/.test(
950
+ node.callee.property.name
951
+ )
952
+ ) {
953
+ // These common array methods (or likely array methods) take synchronous callbacks
954
+ scopeStack.syncCallbacks.add(node.arguments[0]);
955
+ }
956
+ }
957
+ if (node.callee.type === "Identifier") {
958
+ if (
959
+ matchImport(
960
+ ["createSignal", "createStore", "createOptimistic", "createOptimisticStore"],
961
+ node.callee.name
962
+ ) &&
963
+ node.parent?.type === "VariableDeclarator"
964
+ ) {
965
+ // Allow using reactive variables in state setter if the current scope is tracked.
966
+ // ex. const [state, setState] = createStore({ ... });
967
+ // setState(() => ({ preferredName: state.firstName, lastName: "Milner" }));
968
+ const setter = getNthDestructuredVar(node.parent.id, 1, context);
969
+ if (setter) {
970
+ for (const reference of setter.references) {
971
+ const { identifier } = reference;
972
+ if (
973
+ !reference.init &&
974
+ reference.isRead() &&
975
+ identifier.parent?.type === "CallExpression"
976
+ ) {
977
+ for (const arg of identifier.parent.arguments) {
978
+ if (isFunctionNode(arg) && !arg.async) {
979
+ scopeStack.syncCallbacks.add(arg);
980
+ }
981
+ }
982
+ }
983
+ }
984
+ }
985
+ } else if (matchImport(["mapArray", "indexArray", "repeat"], node.callee.name)) {
986
+ const arg1 = node.arguments[1];
987
+ if (isFunctionNode(arg1)) {
988
+ scopeStack.syncCallbacks.add(arg1);
989
+ }
990
+ }
991
+ }
992
+ // Handle IIFEs
993
+ if (isFunctionNode(node.callee)) {
994
+ scopeStack.syncCallbacks.add(node.callee);
995
+ }
996
+ };
997
+
998
+ /** Checks VariableDeclarators, AssignmentExpressions, and CallExpressions for reactivity. */
999
+ const checkForReactiveAssignment = (
1000
+ id: T.BindingName | T.AssignmentExpression["left"] | null,
1001
+ init: T.Node
1002
+ ) => {
1003
+ init = ignoreTransparentWrappers(init);
1004
+
1005
+ // Mark return values of certain functions as reactive
1006
+ if (init.type === "CallExpression" && init.callee.type === "Identifier") {
1007
+ const { callee } = init;
1008
+ if (matchImport(["createSignal", "useTransition", "createOptimistic"], callee.name)) {
1009
+ const signal = id && getNthDestructuredVar(id, 0, context);
1010
+ if (signal) {
1011
+ scopeStack.pushSignal(signal, currentScope().node);
1012
+ } else {
1013
+ warnShouldDestructure(id ?? init, "first");
1014
+ }
1015
+ } else if (matchImport(["createMemo", "createSelector"], callee.name)) {
1016
+ const memo = id && getReturnedVar(id, context);
1017
+ // memos act like signals
1018
+ if (memo) {
1019
+ scopeStack.pushSignal(memo, currentScope().node);
1020
+ } else {
1021
+ warnShouldAssign(id ?? init);
1022
+ }
1023
+ } else if (matchImport(["createStore", "createOptimisticStore"], callee.name)) {
1024
+ const store = id && getNthDestructuredVar(id, 0, context);
1025
+ // stores act like props
1026
+ if (store) {
1027
+ scopeStack.pushProps(store, currentScope().node);
1028
+ } else {
1029
+ warnShouldDestructure(id ?? init, "first");
1030
+ }
1031
+ } else if (matchImport(["mergeProps", "merge", "omit"], callee.name)) {
1032
+ // Solid 2.0 renames mergeProps to merge; omit (which replaces splitProps)
1033
+ // also returns a single props object
1034
+ const merged = id && getReturnedVar(id, context);
1035
+ if (merged) {
1036
+ scopeStack.pushProps(merged, currentScope().node);
1037
+ } else {
1038
+ warnShouldAssign(id ?? init);
1039
+ }
1040
+ } else if (matchImport("splitProps", callee.name)) {
1041
+ // splitProps can return an unbounded array of props variables, though it's most often two
1042
+ if (id?.type === "ArrayPattern") {
1043
+ const vars = id.elements
1044
+ .map((_, i) => getNthDestructuredVar(id, i, context))
1045
+ .filter(Boolean) as Array<Variable>;
1046
+ if (vars.length === 0) {
1047
+ warnShouldDestructure(id);
1048
+ } else {
1049
+ vars.forEach((variable) => {
1050
+ scopeStack.pushProps(variable, currentScope().node);
1051
+ });
1052
+ }
1053
+ } else {
1054
+ // if it's returned as an array, treat that as a props object
1055
+ const vars = id && getReturnedVar(id, context);
1056
+ if (vars) {
1057
+ scopeStack.pushProps(vars, currentScope().node);
1058
+ }
1059
+ }
1060
+ } else if (matchImport("createResource", callee.name)) {
1061
+ // createResource return value has reactive .loading and .error
1062
+ const resourceReturn = id && getNthDestructuredVar(id, 0, context);
1063
+ if (resourceReturn) {
1064
+ scopeStack.pushProps(resourceReturn, currentScope().node);
1065
+ }
1066
+ } else if (matchImport(["createMutable", "createProjection"], callee.name)) {
1067
+ // createProjection (Solid 2.0) returns a readonly derived store
1068
+ const mutable = id && getReturnedVar(id, context);
1069
+ if (mutable) {
1070
+ scopeStack.pushProps(mutable, currentScope().node);
1071
+ }
1072
+ } else if (matchImport("mapArray", callee.name)) {
1073
+ const arg1 = init.arguments[1];
1074
+ if (
1075
+ isFunctionNode(arg1) &&
1076
+ arg1.params.length >= 2 &&
1077
+ arg1.params[1].type === "Identifier"
1078
+ ) {
1079
+ const indexSignal = findVariable(context, arg1.params[1]);
1080
+ if (indexSignal) {
1081
+ scopeStack.pushSignal(indexSignal);
1082
+ }
1083
+ }
1084
+ } else if (matchImport("indexArray", callee.name)) {
1085
+ const arg1 = init.arguments[1];
1086
+ if (
1087
+ isFunctionNode(arg1) &&
1088
+ arg1.params.length >= 1 &&
1089
+ arg1.params[0].type === "Identifier"
1090
+ ) {
1091
+ const valueSignal = findVariable(context, arg1.params[0]);
1092
+ if (valueSignal) {
1093
+ scopeStack.pushSignal(valueSignal);
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+ };
1099
+
1100
+ const checkForTrackedScopes = (
1101
+ node:
1102
+ | T.JSXExpressionContainer
1103
+ | T.JSXSpreadAttribute
1104
+ | T.CallExpression
1105
+ | T.VariableDeclarator
1106
+ | T.AssignmentExpression
1107
+ | T.TaggedTemplateExpression
1108
+ | T.NewExpression
1109
+ ) => {
1110
+ const pushTrackedScope = (
1111
+ node: T.Node,
1112
+ expect: TrackedScope["expect"],
1113
+ allowAsync = false
1114
+ ) => {
1115
+ currentScope().trackedScopes.push({ node, expect });
1116
+ if (
1117
+ expect !== "called-function" &&
1118
+ isFunctionNode(node) &&
1119
+ (node.async || node.generator)
1120
+ ) {
1121
+ if (allowAsync) {
1122
+ // Async computations are first-class in Solid 2.0 (async createMemo,
1123
+ // function-form createSignal/createStore, etc.). Reads before the first
1124
+ // suspension point are tracked normally; reads after it get a targeted
1125
+ // readAfterAwait report in onFunctionExit instead of a blanket warning.
1126
+ asyncTrackedScopes.add(node);
1127
+ } else if (node.async) {
1128
+ // From the docs: "[Solid's] approach only tracks synchronously. If you
1129
+ // have a setTimeout or use an async function in your Effect the code
1130
+ // that executes async after the fact won't be tracked."
1131
+ context.report({
1132
+ node,
1133
+ messageId: "noAsyncTrackedScope",
1134
+ });
1135
+ }
1136
+ }
1137
+ };
1138
+ // given some expression, mark any functions within it as tracking scopes, and do not traverse
1139
+ // those functions
1140
+ const permissivelyTrackNode = (node: T.Node) => {
1141
+ traverse(node as any, {
1142
+ enter(cn) {
1143
+ const childNode = cn as T.Node;
1144
+ const traced = trace(childNode, context);
1145
+ // when referencing a function or something that could be a derived signal, track it
1146
+ if (
1147
+ isFunctionNode(traced) ||
1148
+ (traced.type === "Identifier" &&
1149
+ traced.parent.type !== "MemberExpression" &&
1150
+ !(traced.parent.type === "CallExpression" && traced.parent.callee === traced)) ||
1151
+ // memo accessors traced to their initializing call, e.g. passing a variable
1152
+ // holding a createMemo result — treat like passing a signal
1153
+ (traced.type === "CallExpression" &&
1154
+ traced.callee.type === "Identifier" &&
1155
+ matchImport(
1156
+ ["createMemo", "createSelector", "children", "createProjection"],
1157
+ traced.callee.name
1158
+ ))
1159
+ ) {
1160
+ pushTrackedScope(childNode, "called-function");
1161
+ this.skip(); // poor-man's `findInScope`: don't enter child scopes
1162
+ }
1163
+ },
1164
+ fallback: "iteration", // Don't crash when encounter unknown node.
1165
+ });
1166
+ };
1167
+
1168
+ if (node.type === "JSXExpressionContainer") {
1169
+ if (
1170
+ node.parent?.type === "JSXAttribute" &&
1171
+ sourceCode.getText(node.parent.name).startsWith("on") &&
1172
+ node.parent.parent?.type === "JSXOpeningElement" &&
1173
+ node.parent.parent.name.type === "JSXIdentifier" &&
1174
+ isDOMElementName(node.parent.parent.name.name)
1175
+ ) {
1176
+ // Expect a function if the attribute is like onClick={}, onclick={}, on:click={}, or
1177
+ // custom events such as on-click={}.
1178
+ // From the docs:
1179
+ // Events are never rebound and the bindings are not reactive, as it is expensive to
1180
+ // attach and detach listeners. Since event handlers are called like any other function
1181
+ // each time an event fires, there is no need for reactivity; simply shortcut your handler
1182
+ // if desired.
1183
+ // What this means here is we actually do consider an event handler a tracked scope
1184
+ // expecting a function, i.e. it's okay to use changing props/signals in the body of the
1185
+ // function, even though the changes don't affect when the handler will run. This is what
1186
+ // "called-function" represents—not quite a tracked scope, but a place where it's okay to
1187
+ // read reactive values.
1188
+ pushTrackedScope(node.expression, "called-function");
1189
+ } else if (
1190
+ node.parent?.type === "JSXAttribute" &&
1191
+ node.parent.name.type === "JSXNamespacedName" &&
1192
+ node.parent.name.namespace.name === "use" &&
1193
+ isFunctionNode(node.expression)
1194
+ ) {
1195
+ // With a `use:` hook, assume that a function passed is a called function.
1196
+ pushTrackedScope(node.expression, "called-function");
1197
+ } else if (
1198
+ node.parent?.type === "JSXAttribute" &&
1199
+ node.parent.name.name === "value" &&
1200
+ node.parent.parent?.type === "JSXOpeningElement" &&
1201
+ isProviderElementName(node.parent.parent.name)
1202
+ ) {
1203
+ // Context providers read `props.value` once, untracked, when they are created (in
1204
+ // both Solid 1.x and 2.0), so a reactive expression here is frozen at its initial
1205
+ // value. Don't push a tracked scope; record the container so reads inside it get
1206
+ // the specific `providerValue` message instead of the generic one.
1207
+ providerValueContainers.add(node);
1208
+ } else if (
1209
+ node.parent?.type === "JSXAttribute" &&
1210
+ node.parent.name?.type === "JSXIdentifier" &&
1211
+ /^static[A-Z]/.test(node.parent.name.name) &&
1212
+ node.parent.parent?.type === "JSXOpeningElement" &&
1213
+ node.parent.parent.name.type === "JSXIdentifier" &&
1214
+ !isDOMElementName(node.parent.parent.name.name)
1215
+ ) {
1216
+ // A caller is passing a value to a prop prefixed with `static` in a component, i.e.
1217
+ // `<Box staticName={...} />`. Since we're considering these props as static in the component
1218
+ // we shouldn't allow passing reactive values to them, as this isn't just ignoring reactivity
1219
+ // like initial*/default*; this is disabling it altogether as a convention. Do nothing.
1220
+ } else if (
1221
+ node.parent?.type === "JSXAttribute" &&
1222
+ node.parent.name.name === "ref" &&
1223
+ isFunctionNode(node.expression)
1224
+ ) {
1225
+ // Callback/function refs are called when an element is created but before it is connected
1226
+ // to the DOM. This is semantically a "called function", so it's fine to read reactive
1227
+ // variables here.
1228
+ pushTrackedScope(node.expression, "called-function");
1229
+ } else if (isJSXElementOrFragment(node.parent) && isFunctionNode(node.expression)) {
1230
+ pushTrackedScope(node.expression, "function"); // functions inline in JSX containers will be tracked
1231
+ } else {
1232
+ pushTrackedScope(node.expression, "expression");
1233
+ }
1234
+ } else if (node.type === "JSXSpreadAttribute") {
1235
+ // allow <div {...props.nestedProps} />; {...props} is already ignored
1236
+ pushTrackedScope(node.argument, "expression");
1237
+ } else if (node.type === "NewExpression") {
1238
+ const {
1239
+ callee,
1240
+ arguments: { 0: arg0 },
1241
+ } = node;
1242
+ if (
1243
+ callee.type === "Identifier" &&
1244
+ arg0 &&
1245
+ // Observers from Standard Web APIs
1246
+ [
1247
+ "IntersectionObserver",
1248
+ "MutationObserver",
1249
+ "PerformanceObserver",
1250
+ "ReportingObserver",
1251
+ "ResizeObserver",
1252
+ ].includes(callee.name)
1253
+ ) {
1254
+ // Observers callbacks are NOT tracked scopes. However, they
1255
+ // don't need to react to updates to reactive variables; it's okay
1256
+ // to poll the current value. Consider them called-function tracked
1257
+ // scopes for our purposes.
1258
+ pushTrackedScope(arg0, "called-function");
1259
+ }
1260
+ } else if (node.type === "CallExpression") {
1261
+ if (node.callee.type === "Identifier") {
1262
+ const {
1263
+ callee,
1264
+ arguments: { 0: arg0, 1: arg1 },
1265
+ } = node;
1266
+ if (
1267
+ matchImport(
1268
+ [
1269
+ "createMemo",
1270
+ "children",
1271
+ "createEffect",
1272
+ "createRenderEffect",
1273
+ "createDeferred",
1274
+ "createComputed",
1275
+ "createSelector",
1276
+ "mapArray",
1277
+ "indexArray",
1278
+ "observable",
1279
+ // Solid 2.0
1280
+ "createTrackedEffect",
1281
+ "isPending",
1282
+ "latest",
1283
+ "resolve",
1284
+ "deep",
1285
+ "repeat",
1286
+ "createErrorBoundary",
1287
+ "createLoadingBoundary",
1288
+ "createRevealOrder",
1289
+ ],
1290
+ callee.name
1291
+ )
1292
+ ) {
1293
+ // Solid 2 computations may settle asynchronously. This does not
1294
+ // extend dependency tracking beyond the first suspension point.
1295
+ pushTrackedScope(
1296
+ arg0,
1297
+ "function",
1298
+ Boolean(matchImport("createMemo", callee.name)) ||
1299
+ (v2 && Boolean(matchImport(["createEffect", "createRenderEffect"], callee.name)))
1300
+ );
1301
+ if (
1302
+ matchImport(["createErrorBoundary", "createLoadingBoundary"], callee.name) &&
1303
+ arg1
1304
+ ) {
1305
+ // Boundary fallbacks (Solid 2.0) are reactive accessors; the error fallback
1306
+ // also receives (err, reset) arguments
1307
+ pushTrackedScope(arg1, "called-function");
1308
+ }
1309
+ if (matchImport("createEffect", callee.name) && isFunctionNode(arg1)) {
1310
+ // Solid 2.0 split effects: createEffect(compute, effect) runs the second
1311
+ // function with the computed value, untracked. In Solid 1.x the second
1312
+ // argument is an initial value, so a function there is safe to treat as a
1313
+ // called function under both semantics.
1314
+ pushTrackedScope(arg1, "called-function");
1315
+ }
1316
+ } else if (matchImport("untrack", callee.name) && arg0) {
1317
+ // untrack's callback is called synchronously but explicitly NOT
1318
+ // tracked: reads inside are sanctioned, and an async callback is
1319
+ // harmless (there's no subscription to lose), so it must not get
1320
+ // the async-tracked-scope or read-after-await treatment (#188).
1321
+ pushTrackedScope(arg0, "called-function");
1322
+ } else if (matchImport("createResource", callee.name)) {
1323
+ // createResource(fetcher), createResource(fetcher, options),
1324
+ // createResource(source, fetcher), or createResource(source, fetcher, options).
1325
+ // Only when the second argument is a function is the first a reactive source
1326
+ // (a sync tracked scope). The fetcher itself is not tracked — it receives the
1327
+ // source's value as an argument and may be async.
1328
+ const arg1IsFunction = arg1 && isFunctionNode(trace(arg1, context));
1329
+ if (arg1IsFunction) {
1330
+ pushTrackedScope(arg0, "function");
1331
+ pushTrackedScope(arg1, "called-function");
1332
+ } else if (arg0) {
1333
+ pushTrackedScope(arg0, "called-function");
1334
+ }
1335
+ } else if (matchImport(["mergeProps", "merge"], callee.name)) {
1336
+ // mergeProps (Solid 1.x) and merge (Solid 2.0) wrap function sources in
1337
+ // createMemo, so function arguments are genuinely tracked scopes.
1338
+ for (const arg of node.arguments) {
1339
+ if (arg.type === "SpreadElement") continue;
1340
+ if (isFunctionNode(trace(arg, context))) {
1341
+ pushTrackedScope(arg, "function");
1342
+ }
1343
+ }
1344
+ } else if (
1345
+ matchImport(
1346
+ [
1347
+ "createSignal",
1348
+ "createStore",
1349
+ "createProjection",
1350
+ "createOptimistic",
1351
+ "createOptimisticStore",
1352
+ ],
1353
+ callee.name
1354
+ ) &&
1355
+ isFunctionNode(arg0)
1356
+ ) {
1357
+ // Solid 2.0 function-form derived primitives: the function argument is a
1358
+ // tracked scope, and may be async (async computations are first-class; only
1359
+ // reads before the first `await` are tracked). In Solid 1.x a function passed
1360
+ // to createSignal is just a stored value, so treating it as a tracked scope
1361
+ // is permissive under both semantics.
1362
+ pushTrackedScope(arg0, "function", true);
1363
+ } else if (
1364
+ matchImport(["onMount", "onCleanup", "onError", "onSettled", "action"], callee.name) ||
1365
+ [
1366
+ // Timers
1367
+ "setInterval",
1368
+ "setTimeout",
1369
+ "setImmediate",
1370
+ "requestAnimationFrame",
1371
+ "requestIdleCallback",
1372
+ ].includes(callee.name)
1373
+ ) {
1374
+ // on* and timers are NOT tracked scopes. However, they
1375
+ // don't need to react to updates to reactive variables; it's okay
1376
+ // to poll the current value. Consider them called-function tracked
1377
+ // scopes for our purposes.
1378
+ pushTrackedScope(arg0, "called-function");
1379
+ } else if (matchImport("on", callee.name)) {
1380
+ // on accepts a signal or an array of signals as its first argument,
1381
+ // and a tracking function as its second
1382
+ if (arg0) {
1383
+ if (arg0.type === "ArrayExpression") {
1384
+ arg0.elements.forEach((element) => {
1385
+ if (element && element?.type !== "SpreadElement") {
1386
+ pushTrackedScope(element, "function");
1387
+ }
1388
+ });
1389
+ } else {
1390
+ pushTrackedScope(arg0, "function");
1391
+ }
1392
+ }
1393
+ if (arg1) {
1394
+ // Since dependencies are known, function can be async
1395
+ pushTrackedScope(arg1, "called-function");
1396
+ }
1397
+ } else if (matchImport("createStore", callee.name) && arg0?.type === "ObjectExpression") {
1398
+ for (const property of arg0.properties) {
1399
+ if (
1400
+ property.type === "Property" &&
1401
+ property.kind === "get" &&
1402
+ isFunctionNode(property.value)
1403
+ ) {
1404
+ pushTrackedScope(property.value, "function");
1405
+ }
1406
+ }
1407
+ } else if (matchImport("runWithOwner", callee.name)) {
1408
+ // runWithOwner(owner, fn) only creates a tracked scope if `owner =
1409
+ // getOwner()` runs in a tracked scope. If owner is a variable,
1410
+ // attempt to detect if it's a tracked scope or not, but if this
1411
+ // can't be done, assume it's a tracked scope.
1412
+ if (arg1) {
1413
+ let isTrackedScope = true;
1414
+ const owner = arg0.type === "Identifier" && findVariable(context, arg0);
1415
+ if (owner) {
1416
+ const decl = owner.defs[0];
1417
+ if (
1418
+ decl &&
1419
+ decl.node.type === "VariableDeclarator" &&
1420
+ decl.node.init?.type === "CallExpression" &&
1421
+ decl.node.init.callee.type === "Identifier" &&
1422
+ matchImport("getOwner", decl.node.init.callee.name)
1423
+ ) {
1424
+ // Check if the function in which getOwner() is called is a tracked scope. If the scopeStack
1425
+ // has moved on from that scope already, assume it's tracked, since that's less intrusive.
1426
+ const ownerFunction = findParent(decl.node, isProgramOrFunctionNode);
1427
+ const scopeStackIndex = scopeStack.findIndex(
1428
+ ({ node }) => ownerFunction === node
1429
+ );
1430
+ if (
1431
+ (scopeStackIndex >= 1 &&
1432
+ !scopeStack[scopeStackIndex - 1].trackedScopes.some(
1433
+ (trackedScope) =>
1434
+ trackedScope.expect === "function" && trackedScope.node === ownerFunction
1435
+ )) ||
1436
+ scopeStackIndex === 0
1437
+ ) {
1438
+ isTrackedScope = false;
1439
+ }
1440
+ }
1441
+ }
1442
+ if (isTrackedScope) {
1443
+ pushTrackedScope(arg1, "function");
1444
+ }
1445
+ }
1446
+ } else if (
1447
+ /^(?:use|create)[A-Z]/.test(callee.name) ||
1448
+ matchesCustomReactive(callee.name)
1449
+ ) {
1450
+ // Custom hooks parameters may or may not be tracking scopes, no way to know.
1451
+ // Assume all identifier/function arguments are tracked scopes, and use "called-function"
1452
+ // to allow async handlers (permissive). Assume non-resolvable args are reactive expressions.
1453
+ for (const arg of node.arguments) {
1454
+ permissivelyTrackNode(arg);
1455
+ }
1456
+ } else if (
1457
+ parentScope()?.trackedScopes.some(
1458
+ (trackedScope) =>
1459
+ (trackedScope.expect === "function" || trackedScope.expect === "called-function") &&
1460
+ trackedScope.node === currentScope().node
1461
+ )
1462
+ ) {
1463
+ // An unknown call inside a tracked scope (e.g. a helper called in an effect
1464
+ // callback). Functions passed to it either run synchronously — still inside the
1465
+ // tracked scope — or run later, where polling current values is fine, like any
1466
+ // called function. Known Solid APIs that intentionally decline to track their
1467
+ // callbacks (like runWithOwner with an untracked owner) match earlier branches.
1468
+ for (const arg of node.arguments) {
1469
+ if (isFunctionNode(arg)) {
1470
+ pushTrackedScope(arg, "called-function");
1471
+ }
1472
+ }
1473
+ }
1474
+ } else if (node.callee.type === "MemberExpression") {
1475
+ const { property } = node.callee;
1476
+ if (
1477
+ property.type === "Identifier" &&
1478
+ property.name === "addEventListener" &&
1479
+ node.arguments.length >= 2
1480
+ ) {
1481
+ // Like `on*` event handlers, mark all `addEventListener` listeners as called functions.
1482
+ pushTrackedScope(node.arguments[1], "called-function");
1483
+ } else if (
1484
+ property.type === "Identifier" &&
1485
+ node.callee.object.type === "Identifier" &&
1486
+ ["window", "globalThis", "self"].includes(node.callee.object.name) &&
1487
+ [
1488
+ "setInterval",
1489
+ "setTimeout",
1490
+ "setImmediate",
1491
+ "requestAnimationFrame",
1492
+ "requestIdleCallback",
1493
+ ].includes(property.name)
1494
+ ) {
1495
+ // `window.setTimeout` etc. behave exactly like the bare timer globals above:
1496
+ // callbacks are called functions, free to poll current reactive values.
1497
+ if (node.arguments[0]) {
1498
+ pushTrackedScope(node.arguments[0], "called-function");
1499
+ }
1500
+ } else if (
1501
+ property.type === "Identifier" &&
1502
+ (/^(?:use|create)[A-Z]/.test(property.name) || matchesCustomReactive(property.name))
1503
+ ) {
1504
+ // Handle custom hook parameters for property access custom hooks
1505
+ for (const arg of node.arguments) {
1506
+ permissivelyTrackNode(arg);
1507
+ }
1508
+ }
1509
+ }
1510
+ } else if (node.type === "VariableDeclarator") {
1511
+ // Solid 1.3 createReactive (renamed createReaction?) returns a track
1512
+ // function, a tracked scope expecting a reactive function. All of the
1513
+ // track function's references where it's called push a tracked scope.
1514
+ if (node.init?.type === "CallExpression" && node.init.callee.type === "Identifier") {
1515
+ if (matchImport(["createReactive", "createReaction"], node.init.callee.name)) {
1516
+ const track = getReturnedVar(node.id, context);
1517
+ if (track) {
1518
+ for (const reference of track.references) {
1519
+ if (
1520
+ !reference.init &&
1521
+ reference.isReadOnly() &&
1522
+ reference.identifier.parent?.type === "CallExpression" &&
1523
+ reference.identifier.parent.callee === reference.identifier
1524
+ ) {
1525
+ const arg0 = reference.identifier.parent.arguments[0];
1526
+ if (arg0) {
1527
+ pushTrackedScope(arg0, "function");
1528
+ }
1529
+ }
1530
+ }
1531
+ }
1532
+ if (isFunctionNode(node.init.arguments[0])) {
1533
+ pushTrackedScope(node.init.arguments[0], "called-function");
1534
+ }
1535
+ }
1536
+ }
1537
+ } else if (node.type === "AssignmentExpression") {
1538
+ if (
1539
+ node.left.type === "MemberExpression" &&
1540
+ node.left.property.type === "Identifier" &&
1541
+ isFunctionNode(node.right) &&
1542
+ /^on[a-z]+$/.test(node.left.property.name)
1543
+ ) {
1544
+ // To allow (questionable) code like the following example:
1545
+ // ref.oninput = () = {
1546
+ // if (!errors[ref.name]) return;
1547
+ // ...
1548
+ // }
1549
+ // where event handlers are manually attached to refs, detect these
1550
+ // scenarios and mark the right hand sides as tracked scopes expecting
1551
+ // functions.
1552
+ pushTrackedScope(node.right, "called-function");
1553
+ }
1554
+ } else if (node.type === "TaggedTemplateExpression") {
1555
+ for (const expression of node.quasi.expressions) {
1556
+ if (isFunctionNode(expression)) {
1557
+ // ex. css`color: ${props => props.color}`. Use "called-function" to allow async handlers (permissive)
1558
+ pushTrackedScope(expression, "called-function");
1559
+
1560
+ // exception case: add a reactive variable within checkForTrackedScopes when a param is props
1561
+ for (const param of expression.params) {
1562
+ if (param.type === "Identifier" && isPropsByName(param.name)) {
1563
+ const variable = findVariable(context, param);
1564
+ if (variable) scopeStack.pushProps(variable, currentScope().node);
1565
+ }
1566
+ }
1567
+ }
1568
+ }
1569
+ }
1570
+ };
1571
+
1572
+ return {
1573
+ ImportDeclaration: handleImportDeclaration,
1574
+ JSXExpressionContainer(node: T.JSXExpressionContainer) {
1575
+ checkForTrackedScopes(node);
1576
+ },
1577
+ JSXSpreadAttribute(node: T.JSXSpreadAttribute) {
1578
+ checkForTrackedScopes(node);
1579
+ },
1580
+ CallExpression(node: T.CallExpression) {
1581
+ checkForTrackedScopes(node);
1582
+ checkForSyncCallbacks(node);
1583
+
1584
+ // ensure calls to reactive primitives use the results. Directly-returned calls
1585
+ // (`return createMemo(...)` or as an arrow body) hand their result to the caller,
1586
+ // like a custom primitive, so there is nothing to capture in this scope.
1587
+ const parent = node.parent && ignoreTransparentWrappers(node.parent, true);
1588
+ if (
1589
+ parent?.type !== "AssignmentExpression" &&
1590
+ parent?.type !== "VariableDeclarator" &&
1591
+ parent?.type !== "ReturnStatement" &&
1592
+ !(parent?.type === "ArrowFunctionExpression" && parent.body === node)
1593
+ ) {
1594
+ if (parent?.type === "CallExpression" && parent.arguments.includes(node)) {
1595
+ // A primitive call passed to a wrapper, like
1596
+ // `const [state, setState] = makePersisted(createSignal(false))`
1597
+ // (#190). The dominant ecosystem contract passes the tuple through,
1598
+ // so if the outermost wrapper's result is captured, analyze the
1599
+ // primitive as if assigned there directly. If the result isn't
1600
+ // captured, the tuple escaped into the wrapper — nothing can be
1601
+ // concluded, so stay silent rather than warn.
1602
+ let outer: T.Node = parent;
1603
+ let outerParent = outer.parent && ignoreTransparentWrappers(outer.parent, true);
1604
+ while (
1605
+ outerParent?.type === "CallExpression" &&
1606
+ outerParent.arguments.includes(outer as T.CallExpressionArgument)
1607
+ ) {
1608
+ outer = outerParent;
1609
+ outerParent = outer.parent && ignoreTransparentWrappers(outer.parent, true);
1610
+ }
1611
+ if (outerParent?.type === "VariableDeclarator") {
1612
+ checkForReactiveAssignment(outerParent.id, node);
1613
+ } else if (
1614
+ outerParent?.type === "AssignmentExpression" &&
1615
+ outerParent.left.type !== "MemberExpression"
1616
+ ) {
1617
+ checkForReactiveAssignment(outerParent.left, node);
1618
+ }
1619
+ } else {
1620
+ checkForReactiveAssignment(null, node);
1621
+ }
1622
+ }
1623
+ },
1624
+ NewExpression(node: T.NewExpression) {
1625
+ checkForTrackedScopes(node);
1626
+ },
1627
+ VariableDeclarator(node: T.VariableDeclarator) {
1628
+ if (node.init) {
1629
+ checkForReactiveAssignment(node.id, node.init);
1630
+ checkForTrackedScopes(node);
1631
+ }
1632
+ },
1633
+ AssignmentExpression(node: T.AssignmentExpression) {
1634
+ if (node.left.type !== "MemberExpression") {
1635
+ checkForReactiveAssignment(node.left, node.right);
1636
+ }
1637
+ checkForTrackedScopes(node);
1638
+ },
1639
+ TaggedTemplateExpression(node: T.TaggedTemplateExpression) {
1640
+ checkForTrackedScopes(node);
1641
+ },
1642
+ "JSXElement > JSXExpressionContainer > :function"(node: T.Node) {
1643
+ if (
1644
+ isFunctionNode(node) &&
1645
+ node.parent?.type === "JSXExpressionContainer" &&
1646
+ node.parent.parent?.type === "JSXElement"
1647
+ ) {
1648
+ const element = node.parent.parent;
1649
+
1650
+ if (element.openingElement.name.type === "JSXIdentifier") {
1651
+ const tagName = element.openingElement.name.name;
1652
+ if (matchImport("For", tagName)) {
1653
+ // In Solid 2.0, <For> callback params depend on the `keyed` prop:
1654
+ // - absent, bare `keyed`, or `keyed={true}`: (item, index-accessor), same as 1.x
1655
+ // - `keyed={false}`: (item-accessor, index), the old <Index> shape
1656
+ // - `keyed={fn}`: (item-accessor, index-accessor)
1657
+ const keyedAttr = element.openingElement.attributes.find(
1658
+ (attr): attr is T.JSXAttribute =>
1659
+ attr.type === "JSXAttribute" &&
1660
+ attr.name.type === "JSXIdentifier" &&
1661
+ attr.name.name === "keyed"
1662
+ );
1663
+ let itemIsSignal = false;
1664
+ let indexIsSignal = true;
1665
+ if (keyedAttr && keyedAttr.value?.type === "JSXExpressionContainer") {
1666
+ const keyedValue = keyedAttr.value.expression;
1667
+ if (keyedValue.type === "Literal" && keyedValue.value === false) {
1668
+ itemIsSignal = true;
1669
+ indexIsSignal = false;
1670
+ } else if (!(keyedValue.type === "Literal" && keyedValue.value === true)) {
1671
+ // key function: both params are accessors
1672
+ itemIsSignal = true;
1673
+ }
1674
+ }
1675
+ if (itemIsSignal && node.params.length >= 1 && node.params[0].type === "Identifier") {
1676
+ const item = findVariable(context, node.params[0]);
1677
+ if (item) {
1678
+ scopeStack.pushSignal(item, currentScope().node);
1679
+ }
1680
+ }
1681
+ if (
1682
+ indexIsSignal &&
1683
+ node.params.length === 2 &&
1684
+ node.params[1].type === "Identifier"
1685
+ ) {
1686
+ // Mark `index` in `<For>{(item, index) => <div /></For>` as a signal
1687
+ const index = findVariable(context, node.params[1]);
1688
+ if (index) {
1689
+ scopeStack.pushSignal(index, currentScope().node);
1690
+ }
1691
+ }
1692
+ } else if (
1693
+ matchImport("Index", tagName) &&
1694
+ node.params.length >= 1 &&
1695
+ node.params[0].type === "Identifier"
1696
+ ) {
1697
+ // Mark `item` in `<Index>{(item, index) => <div />}</Index>` as a signal
1698
+ const item = findVariable(context, node.params[0]);
1699
+ if (item) {
1700
+ scopeStack.pushSignal(item, currentScope().node);
1701
+ }
1702
+ }
1703
+ }
1704
+ }
1705
+ },
1706
+
1707
+ /* Function enter/exit */
1708
+ FunctionExpression: onFunctionEnter,
1709
+ ArrowFunctionExpression: onFunctionEnter,
1710
+ FunctionDeclaration: onFunctionEnter,
1711
+ Program: onFunctionEnter,
1712
+ "FunctionExpression:exit": onFunctionExit,
1713
+ "ArrowFunctionExpression:exit": onFunctionExit,
1714
+ "FunctionDeclaration:exit": onFunctionExit,
1715
+ "Program:exit": onFunctionExit,
1716
+
1717
+ /* Detect JSX for adding props */
1718
+ JSXElement() {
1719
+ if (scopeStack.length) {
1720
+ currentScope().hasJSX = true;
1721
+ }
1722
+ },
1723
+ JSXFragment() {
1724
+ if (scopeStack.length) {
1725
+ currentScope().hasJSX = true;
1726
+ }
1727
+ },
1728
+ };
1729
+ },
1730
+ });