eslint-plugin-solid
Version:
Solid-specific linting rules for ESLint.
1,189 lines (1,144 loc) • 51.9 kB
text/typescript
/**
* File overview here, scroll to bottom.
* @link https://github.com/solidjs-community/eslint-plugin-solid/blob/main/docs/reactivity.md
*/
import { TSESTree as T, TSESLint, ESLintUtils, ASTUtils } from "@typescript-eslint/utils";
import { traverse } from "estraverse";
import {
findParent,
findInScope,
isPropsByName,
FunctionNode,
isFunctionNode,
ProgramOrFunctionNode,
isProgramOrFunctionNode,
trackImports,
isDOMElementName,
ignoreTransparentWrappers,
getFunctionName,
isJSXElementOrFragment,
trace,
} from "../utils";
import { findVariable, CompatContext, getSourceCode } from "../compat";
const { getFunctionHeadLocation } = ASTUtils;
const createRule = ESLintUtils.RuleCreator.withoutDocs;
type Variable = TSESLint.Scope.Variable;
type Reference = TSESLint.Scope.Reference;
interface ReactiveVariable {
/**
* The reactive variable references we're concerned with (i.e. not init).
* References are removed after they are analyzed.
*/
references: Array<Reference>;
/**
* The function node in which the reactive variable was declared, or for a
* derived signal (function), the deepest function node that declares a
* referenced signal.
*/
declarationScope: ProgramOrFunctionNode;
/**
* The reactive variable. Not used directly, only needed for identification
* in pushUniqueDerivedSignal.
*/
variable: Variable;
}
interface TrackedScope {
/**
* The root node, usually a function or JSX expression container, to allow
* reactive variables under.
*/
node: T.Node;
/**
* The reactive variable should be one of these types:
* - "function": synchronous function or signal variable
* - "called-function": synchronous or asynchronous function like a timer or
* event handler that isn't really a tracked scope but allows reactivity
* - "expression": some value containing reactivity somewhere
*/
expect: "function" | "called-function" | "expression";
}
class ScopeStackItem {
/** the node for the current scope, or program if global scope */
node: ProgramOrFunctionNode;
/**
* nodes whose descendants in the current scope are allowed to be reactive.
* JSXExpressionContainers can be any expression containing reactivity, while
* function nodes/identifiers are typically arguments to solid-js primitives
* and should match a tracked scope exactly.
*/
trackedScopes: Array<TrackedScope> = [];
/** nameless functions with reactivity, should exactly match a tracked scope */
unnamedDerivedSignals = new Set<FunctionNode>();
/** switched to true by time of :exit if JSX is detected in the current scope */
hasJSX = false;
constructor(node: ProgramOrFunctionNode) {
this.node = node;
}
}
class ScopeStack extends Array<ScopeStackItem> {
currentScope = () => this[this.length - 1];
parentScope = () => this[this.length - 2];
/** Add references to a signal, memo, derived signal, etc. */
pushSignal(
variable: Variable,
declarationScope: ProgramOrFunctionNode = this.currentScope().node
) {
this.signals.push({
references: variable.references.filter((reference) => !reference.init),
variable,
declarationScope,
});
}
/**
* Add references to a signal, merging with existing references if the
* variable is the same. Derived signals are special; they don't use the
* declaration scope of the function, but rather the minimum declaration scope
* of any signals they contain.
*/
pushUniqueSignal(variable: Variable, declarationScope: ProgramOrFunctionNode) {
const foundSignal = this.signals.find((s) => s.variable === variable);
if (!foundSignal) {
this.pushSignal(variable, declarationScope);
} else {
foundSignal.declarationScope = this.findDeepestDeclarationScope(
foundSignal.declarationScope,
declarationScope
);
}
}
/** Add references to a props or store. */
pushProps(
variable: Variable,
declarationScope: ProgramOrFunctionNode = this.currentScope().node
) {
this.props.push({
references: variable.references.filter((reference) => !reference.init),
variable,
declarationScope,
});
}
/** Function callbacks that run synchronously and don't create a new scope. */
syncCallbacks = new Set<FunctionNode>();
/**
* Iterate through and remove the signal references in the current scope.
* That way, the next Scope up can safely check for references in its scope.
*/
*consumeSignalReferencesInScope() {
yield* this.consumeReferencesInScope(this.signals);
this.signals = this.signals.filter((variable) => variable.references.length !== 0);
}
/** Iterate through and remove the props references in the current scope. */
*consumePropsReferencesInScope() {
yield* this.consumeReferencesInScope(this.props);
this.props = this.props.filter((variable) => variable.references.length !== 0);
}
private *consumeReferencesInScope(
variables: Array<ReactiveVariable>
): Iterable<{ reference: Reference; declarationScope: ProgramOrFunctionNode }> {
for (const variable of variables) {
const { references } = variable;
const inScope: Array<Reference> = [],
notInScope: Array<Reference> = [];
references.forEach((reference) => {
if (this.isReferenceInCurrentScope(reference)) {
inScope.push(reference);
} else {
notInScope.push(reference);
}
});
yield* inScope.map((reference) => ({
reference,
declarationScope: variable.declarationScope,
}));
// I don't think this is needed! Just a perf optimization
variable.references = notInScope;
}
}
/** Returns the function node deepest in the tree. Assumes a === b, a is inside b, or b is inside a. */
private findDeepestDeclarationScope = (
a: ProgramOrFunctionNode,
b: ProgramOrFunctionNode
): ProgramOrFunctionNode => {
if (a === b) return a;
for (let i = this.length - 1; i >= 0; i -= 1) {
const { node } = this[i];
if (a === node || b === node) {
return node;
}
}
throw new Error("This should never happen");
};
/**
* Returns true if the reference is in the current scope, handling sync
* callbacks. Must be called on the :exit pass only.
*/
private isReferenceInCurrentScope(reference: Reference) {
let parentFunction = findParent(reference.identifier, isProgramOrFunctionNode);
while (isFunctionNode(parentFunction) && this.syncCallbacks.has(parentFunction)) {
parentFunction = findParent(parentFunction, isProgramOrFunctionNode);
}
return parentFunction === this.currentScope().node;
}
/** variable references to be treated as signals, memos, derived signals, etc. */
private signals: Array<ReactiveVariable> = [];
/** variables references to be treated as props (or stores) */
private props: Array<ReactiveVariable> = [];
}
const getNthDestructuredVar = (id: T.Node, n: number, context: CompatContext): Variable | null => {
if (id?.type === "ArrayPattern") {
const el = id.elements[n];
if (el?.type === "Identifier") {
return findVariable(context, el);
}
}
return null;
};
const getReturnedVar = (id: T.Node, context: CompatContext): Variable | null => {
if (id.type === "Identifier") {
return findVariable(context, id);
}
return null;
};
type MessageIds =
| "noWrite"
| "untrackedReactive"
| "expectedFunctionGotExpression"
| "badSignal"
| "badUnnamedDerivedSignal"
| "shouldDestructure"
| "shouldAssign"
| "noAsyncTrackedScope";
type Options = [{ customReactiveFunctions: string[] }];
export default createRule<Options, MessageIds>({
meta: {
type: "problem",
docs: {
description:
"Enforce that reactivity (props, signals, memos, etc.) is properly used, so changes in those values will be tracked and update the view as expected.",
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/reactivity.md",
},
schema: [
{
type: "object",
properties: {
customReactiveFunctions: {
description:
"List of function names to consider as reactive functions (allow signals to be safely passed as arguments). In addition, any create* or use* functions are automatically included.",
type: "array",
items: {
type: "string",
},
default: [],
},
},
additionalProperties: false,
},
],
messages: {
noWrite: "The reactive variable '{{name}}' should not be reassigned or altered directly.",
untrackedReactive:
"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.",
expectedFunctionGotExpression:
"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.",
badSignal:
"The reactive variable '{{name}}' should be called as a function when used in {{where}}.",
badUnnamedDerivedSignal:
"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.",
shouldDestructure:
"For proper analysis, array destructuring should be used to capture the {{nth}}result of this function call.",
shouldAssign:
"For proper analysis, a variable should be used to capture the result of this function call.",
noAsyncTrackedScope:
"This tracked scope should not be async. Solid's reactivity only tracks synchronously.",
},
},
defaultOptions: [
{
customReactiveFunctions: [],
},
],
create(context, [options]) {
const warnShouldDestructure = (node: T.Node, nth?: string) =>
context.report({
node,
messageId: "shouldDestructure",
data: nth ? { nth: nth + " " } : undefined,
});
const warnShouldAssign = (node: T.Node) => context.report({ node, messageId: "shouldAssign" });
const sourceCode = getSourceCode(context);
/** Represents the lexical function stack and relevant information for each function */
const scopeStack = new ScopeStack();
const { currentScope, parentScope } = scopeStack;
/** Tracks imports from 'solid-js', handling aliases. */
const { matchImport, handleImportDeclaration } = trackImports();
/** Workaround for #61 */
const markPropsOnCondition = (node: FunctionNode, cb: (props: T.Identifier) => boolean) => {
if (
node.params.length === 1 &&
node.params[0].type === "Identifier" &&
node.parent?.type !== "JSXExpressionContainer" && // "render props" aren't components
node.parent?.type !== "TemplateLiteral" && // inline functions in tagged template literals aren't components
cb(node.params[0])
) {
// This function is a component, consider its parameter a props
const propsParam = findVariable(context, node.params[0]);
if (propsParam) {
scopeStack.pushProps(propsParam, node);
}
}
};
/** Populates the function stack. */
const onFunctionEnter = (node: ProgramOrFunctionNode) => {
if (isFunctionNode(node)) {
if (scopeStack.syncCallbacks.has(node)) {
// Ignore sync callbacks like Array#forEach and certain Solid primitives
return;
}
markPropsOnCondition(node, (props) => isPropsByName(props.name));
}
scopeStack.push(new ScopeStackItem(node));
};
/** Returns whether a node falls under a tracked scope in the current function scope */
const matchTrackedScope = (trackedScope: TrackedScope, node: T.Node): boolean => {
switch (trackedScope.expect) {
case "function":
case "called-function":
return node === trackedScope.node;
case "expression":
return Boolean(
findInScope(node, currentScope().node, (node) => node === trackedScope.node)
);
}
};
/** Inspects a specific reference of a reactive variable for correct handling. */
const handleTrackedScopes = (
identifier: T.Identifier,
declarationScope: ProgramOrFunctionNode
) => {
const currentScopeNode = currentScope().node;
// Check if the call falls outside any tracked scopes in the current scope
if (
!currentScope().trackedScopes.find((trackedScope) =>
matchTrackedScope(trackedScope, identifier)
)
) {
const matchedExpression = currentScope().trackedScopes.find((trackedScope) =>
matchTrackedScope({ ...trackedScope, expect: "expression" }, identifier)
);
if (declarationScope === currentScopeNode) {
// If the reactivity is not contained in a tracked scope, and any of
// the reactive variables were declared in the current scope, then we
// report them. When the reference is to an object in a
// MemberExpression (props/store) or a function call (signal), report
// that, otherwise the identifier.
let parentMemberExpression: T.MemberExpression | null = null;
if (identifier.parent?.type === "MemberExpression") {
parentMemberExpression = identifier.parent;
while (parentMemberExpression!.parent?.type === "MemberExpression") {
parentMemberExpression = parentMemberExpression!.parent;
}
}
const parentCallExpression =
identifier.parent?.type === "CallExpression" ? identifier.parent : null;
context.report({
node: parentMemberExpression ?? parentCallExpression ?? identifier,
messageId: matchedExpression ? "expectedFunctionGotExpression" : "untrackedReactive",
data: {
name: parentMemberExpression
? sourceCode.getText(parentMemberExpression)
: identifier.name,
},
});
} else {
// If all of the reactive variables were declared above the current
// function scope, then the entire function becomes reactive with the
// deepest declaration scope of the reactive variables it contains.
// Let the next onFunctionExit up handle it.
if (!parentScope() || !isFunctionNode(currentScopeNode)) {
throw new Error("this shouldn't happen!");
}
// If the current function doesn't have an associated variable, that's
// fine, it's being used inline (i.e. anonymous arrow function). For
// this to be okay, the arrow function has to be the same node as one
// of the tracked scopes, as we can't easily find references.
const pushUnnamedDerivedSignal = () =>
(parentScope().unnamedDerivedSignals ??= new Set()).add(currentScopeNode);
if (currentScopeNode.type === "FunctionDeclaration") {
// get variable representing function, function node only defines one variable
const functionVariable: Variable | undefined =
sourceCode.scopeManager?.getDeclaredVariables(currentScopeNode)?.[0];
if (functionVariable) {
scopeStack.pushUniqueSignal(
functionVariable,
declarationScope // use declaration scope of a signal contained in this function
);
} else {
pushUnnamedDerivedSignal();
}
} else if (currentScopeNode.parent?.type === "VariableDeclarator") {
const declarator = currentScopeNode.parent;
// for nameless or arrow function expressions, use the declared variable it's assigned to
const functionVariable = sourceCode.scopeManager?.getDeclaredVariables(declarator)?.[0];
if (functionVariable) {
// use declaration scope of a signal contained in this scope, not the function itself
scopeStack.pushUniqueSignal(functionVariable, declarationScope);
} else {
pushUnnamedDerivedSignal();
}
} else if (currentScopeNode.parent?.type === "Property") {
// todo make this a unique props or something--for now, just ignore (unsafe)
} else {
pushUnnamedDerivedSignal();
}
}
}
};
/** Performs all analysis and reporting. */
const onFunctionExit = (currentScopeNode: ProgramOrFunctionNode) => {
// If this function is a component, add its props as a reactive variable
if (isFunctionNode(currentScopeNode)) {
markPropsOnCondition(currentScopeNode, (props) => {
if (
!isPropsByName(props.name) && // already added in markPropsOnEnter
currentScope().hasJSX
) {
const functionName = getFunctionName(currentScopeNode);
// begins with lowercase === not component
if (functionName && !/^[a-z]/.test(functionName)) return true;
}
return false;
});
}
// Ignore sync callbacks like Array#forEach and certain Solid primitives.
// In this case only, currentScopeNode !== currentScope().node, but we're
// returning early so it doesn't matter.
if (isFunctionNode(currentScopeNode) && scopeStack.syncCallbacks.has(currentScopeNode)) {
return;
}
// Iterate through all usages of (derived) signals in the current scope
for (const { reference, declarationScope } of scopeStack.consumeSignalReferencesInScope()) {
const identifier = reference.identifier;
if (reference.isWrite()) {
// don't allow reassigning signals
context.report({
node: identifier,
messageId: "noWrite",
data: {
name: identifier.name,
},
});
} else if (identifier.type === "Identifier") {
const reportBadSignal = (where: string) =>
context.report({
node: identifier,
messageId: "badSignal",
data: { name: identifier.name, where },
});
if (
// This allows both calling a signal and calling a function with a signal.
identifier.parent?.type === "CallExpression" ||
// Also allow the case where we pass an array of signals, such as in a custom hook
(identifier.parent?.type === "ArrayExpression" &&
identifier.parent.parent?.type === "CallExpression")
) {
// This signal is getting called properly, analyze it.
handleTrackedScopes(identifier, declarationScope);
} else if (identifier.parent?.type === "TemplateLiteral") {
reportBadSignal("template literals");
} else if (
identifier.parent?.type === "BinaryExpression" &&
[
"<",
"<=",
">",
">=",
"<<",
">>",
">>>",
"+",
"-",
"*",
"/",
"%",
"**",
"|",
"^",
"&",
"in",
].includes(identifier.parent.operator)
) {
// We're in an arithmetic/comparison expression where using an uncalled signal wouldn't make sense
reportBadSignal("arithmetic or comparisons");
} else if (
identifier.parent?.type === "UnaryExpression" &&
["-", "+", "~"].includes(identifier.parent.operator)
) {
// We're in a unary expression where using an uncalled signal wouldn't make sense
reportBadSignal("unary expressions");
} else if (
identifier.parent?.type === "MemberExpression" &&
identifier.parent.computed &&
identifier.parent.property === identifier
) {
// We're using an uncalled signal to index an object or array, which doesn't make sense
reportBadSignal("property accesses");
} else if (
identifier.parent?.type === "JSXExpressionContainer" &&
!currentScope().trackedScopes.find(
(trackedScope) =>
trackedScope.node === identifier &&
(trackedScope.expect === "function" || trackedScope.expect === "called-function")
)
) {
// If the signal is in a JSXExpressionContainer that's also marked as a "function" or "called-function" tracked scope,
// let it be.
const elementOrAttribute = identifier.parent.parent;
if (
// The signal is not being called and is being used as a props.children, where calling
// the signal was the likely intent.
isJSXElementOrFragment(elementOrAttribute) ||
// We can't say for sure about user components, but we know for a fact that a signal
// should not be passed to a non-event handler DOM element attribute without calling it.
(elementOrAttribute?.type === "JSXAttribute" &&
elementOrAttribute.parent?.type === "JSXOpeningElement" &&
elementOrAttribute.parent.name.type === "JSXIdentifier" &&
isDOMElementName(elementOrAttribute.parent.name.name))
) {
reportBadSignal("JSX");
}
}
}
// The signal is being read outside of a CallExpression. Since
// there's a lot of possibilities here and they're generally fine,
// do nothing.
}
// Do a similar thing with all usages of props in the current function
for (const { reference, declarationScope } of scopeStack.consumePropsReferencesInScope()) {
const identifier = reference.identifier;
if (reference.isWrite()) {
// don't allow reassigning props or stores
context.report({
node: identifier,
messageId: "noWrite",
data: {
name: identifier.name,
},
});
} else if (
identifier.parent?.type === "MemberExpression" &&
identifier.parent.object === identifier
) {
const { parent } = identifier;
if (parent.parent?.type === "AssignmentExpression" && parent.parent.left === parent) {
// don't allow writing to props or stores directly
context.report({
node: identifier,
messageId: "noWrite",
data: {
name: identifier.name,
},
});
} else if (
parent.property.type === "Identifier" &&
/^(?:initial|default|static[A-Z])/.test(parent.property.name)
) {
// We're using a prop with a name that starts with `initial` or
// `default`, like `props.initialCount`. We'll refrain from warning
// about untracked usages of these props, because the user has shown
// that they understand the consequences of using a reactive
// variable to initialize something else. Do nothing.
} else {
// The props are the object in a property read access, which
// should be under a tracked scope.
handleTrackedScopes(identifier, declarationScope);
}
} else if (
identifier.parent?.type === "AssignmentExpression" ||
identifier.parent?.type === "VariableDeclarator"
) {
// There's no reason to allow `... = props`, it's usually destructuring, which breaks reactivity.
context.report({
node: identifier,
messageId: "untrackedReactive",
data: { name: identifier.name },
});
}
// The props are being read, but not in a MemberExpression. Since
// there's a lot of possibilities here and they're generally fine,
// do nothing.
}
// If there are any unnamed derived signals, they must match a tracked
// scope. Usually anonymous arrow function args to createEffect,
// createMemo, etc.
const { unnamedDerivedSignals } = currentScope();
if (unnamedDerivedSignals) {
for (const node of unnamedDerivedSignals) {
if (
!currentScope().trackedScopes.find((trackedScope) =>
matchTrackedScope(trackedScope, node)
)
) {
context.report({
loc: getFunctionHeadLocation(node, sourceCode),
messageId: "badUnnamedDerivedSignal",
});
}
}
}
// Pop on exit
scopeStack.pop();
};
/*
* Sync array functions (forEach, map, reduce, reduceRight, flatMap),
* store update fn params (ex. setState("todos", (t) => [...t.slice(0, i()),
* ...t.slice(i() + 1)])), batch, onCleanup, and onError fn params, and
* maybe a few others don't actually create a new scope. That is, any
* signal/prop accesses in these functions act as if they happen in the
* enclosing function. Note that this means whether or not the enclosing
* function is a tracking scope applies to the fn param as well.
*
* Every time a sync callback is detected, we put that function node into a
* syncCallbacks Set<FunctionNode>. The detections must happen on the entry pass
* and when the function node has not yet been traversed. In onFunctionEnter, if
* the function node is in syncCallbacks, we don't push it onto the
* scopeStack. In onFunctionExit, if the function node is in syncCallbacks,
* we don't pop scopeStack.
*/
const checkForSyncCallbacks = (node: T.CallExpression) => {
if (
node.arguments.length === 1 &&
isFunctionNode(node.arguments[0]) &&
!node.arguments[0].async
) {
if (
node.callee.type === "Identifier" &&
matchImport(["batch", "produce"], node.callee.name)
) {
// These Solid APIs take callbacks that run in the current scope
scopeStack.syncCallbacks.add(node.arguments[0]);
} else if (
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
node.callee.object.type !== "ObjectExpression" &&
/^(?:forEach|map|flatMap|reduce|reduceRight|find|findIndex|filter|every|some)$/.test(
node.callee.property.name
)
) {
// These common array methods (or likely array methods) take synchronous callbacks
scopeStack.syncCallbacks.add(node.arguments[0]);
}
}
if (node.callee.type === "Identifier") {
if (
matchImport(["createSignal", "createStore"], node.callee.name) &&
node.parent?.type === "VariableDeclarator"
) {
// Allow using reactive variables in state setter if the current scope is tracked.
// ex. const [state, setState] = createStore({ ... });
// setState(() => ({ preferredName: state.firstName, lastName: "Milner" }));
const setter = getNthDestructuredVar(node.parent.id, 1, context);
if (setter) {
for (const reference of setter.references) {
const { identifier } = reference;
if (
!reference.init &&
reference.isRead() &&
identifier.parent?.type === "CallExpression"
) {
for (const arg of identifier.parent.arguments) {
if (isFunctionNode(arg) && !arg.async) {
scopeStack.syncCallbacks.add(arg);
}
}
}
}
}
} else if (matchImport(["mapArray", "indexArray"], node.callee.name)) {
const arg1 = node.arguments[1];
if (isFunctionNode(arg1)) {
scopeStack.syncCallbacks.add(arg1);
}
}
}
// Handle IIFEs
if (isFunctionNode(node.callee)) {
scopeStack.syncCallbacks.add(node.callee);
}
};
/** Checks VariableDeclarators, AssignmentExpressions, and CallExpressions for reactivity. */
const checkForReactiveAssignment = (
id: T.BindingName | T.AssignmentExpression["left"] | null,
init: T.Node
) => {
init = ignoreTransparentWrappers(init);
// Mark return values of certain functions as reactive
if (init.type === "CallExpression" && init.callee.type === "Identifier") {
const { callee } = init;
if (matchImport(["createSignal", "useTransition"], callee.name)) {
const signal = id && getNthDestructuredVar(id, 0, context);
if (signal) {
scopeStack.pushSignal(signal, currentScope().node);
} else {
warnShouldDestructure(id ?? init, "first");
}
} else if (matchImport(["createMemo", "createSelector"], callee.name)) {
const memo = id && getReturnedVar(id, context);
// memos act like signals
if (memo) {
scopeStack.pushSignal(memo, currentScope().node);
} else {
warnShouldAssign(id ?? init);
}
} else if (matchImport("createStore", callee.name)) {
const store = id && getNthDestructuredVar(id, 0, context);
// stores act like props
if (store) {
scopeStack.pushProps(store, currentScope().node);
} else {
warnShouldDestructure(id ?? init, "first");
}
} else if (matchImport("mergeProps", callee.name)) {
const merged = id && getReturnedVar(id, context);
if (merged) {
scopeStack.pushProps(merged, currentScope().node);
} else {
warnShouldAssign(id ?? init);
}
} else if (matchImport("splitProps", callee.name)) {
// splitProps can return an unbounded array of props variables, though it's most often two
if (id?.type === "ArrayPattern") {
const vars = id.elements
.map((_, i) => getNthDestructuredVar(id, i, context))
.filter(Boolean) as Array<Variable>;
if (vars.length === 0) {
warnShouldDestructure(id);
} else {
vars.forEach((variable) => {
scopeStack.pushProps(variable, currentScope().node);
});
}
} else {
// if it's returned as an array, treat that as a props object
const vars = id && getReturnedVar(id, context);
if (vars) {
scopeStack.pushProps(vars, currentScope().node);
}
}
} else if (matchImport("createResource", callee.name)) {
// createResource return value has reactive .loading and .error
const resourceReturn = id && getNthDestructuredVar(id, 0, context);
if (resourceReturn) {
scopeStack.pushProps(resourceReturn, currentScope().node);
}
} else if (matchImport("createMutable", callee.name)) {
const mutable = id && getReturnedVar(id, context);
if (mutable) {
scopeStack.pushProps(mutable, currentScope().node);
}
} else if (matchImport("mapArray", callee.name)) {
const arg1 = init.arguments[1];
if (
isFunctionNode(arg1) &&
arg1.params.length >= 2 &&
arg1.params[1].type === "Identifier"
) {
const indexSignal = findVariable(context, arg1.params[1]);
if (indexSignal) {
scopeStack.pushSignal(indexSignal);
}
}
} else if (matchImport("indexArray", callee.name)) {
const arg1 = init.arguments[1];
if (
isFunctionNode(arg1) &&
arg1.params.length >= 1 &&
arg1.params[0].type === "Identifier"
) {
const valueSignal = findVariable(context, arg1.params[0]);
if (valueSignal) {
scopeStack.pushSignal(valueSignal);
}
}
}
}
};
const checkForTrackedScopes = (
node:
| T.JSXExpressionContainer
| T.JSXSpreadAttribute
| T.CallExpression
| T.VariableDeclarator
| T.AssignmentExpression
| T.TaggedTemplateExpression
| T.NewExpression
) => {
const pushTrackedScope = (node: T.Node, expect: TrackedScope["expect"]) => {
currentScope().trackedScopes.push({ node, expect });
if (expect !== "called-function" && isFunctionNode(node) && node.async) {
// From the docs: "[Solid's] approach only tracks synchronously. If you
// have a setTimeout or use an async function in your Effect the code
// that executes async after the fact won't be tracked."
context.report({
node,
messageId: "noAsyncTrackedScope",
});
}
};
// given some expression, mark any functions within it as tracking scopes, and do not traverse
// those functions
const permissivelyTrackNode = (node: T.Node) => {
traverse(node as any, {
enter(cn) {
const childNode = cn as T.Node;
const traced = trace(childNode, context);
// when referencing a function or something that could be a derived signal, track it
if (
isFunctionNode(traced) ||
(traced.type === "Identifier" &&
traced.parent.type !== "MemberExpression" &&
!(traced.parent.type === "CallExpression" && traced.parent.callee === traced))
) {
pushTrackedScope(childNode, "called-function");
this.skip(); // poor-man's `findInScope`: don't enter child scopes
}
},
fallback: "iteration", // Don't crash when encounter unknown node.
});
};
if (node.type === "JSXExpressionContainer") {
if (
node.parent?.type === "JSXAttribute" &&
sourceCode.getText(node.parent.name).startsWith("on") &&
node.parent.parent?.type === "JSXOpeningElement" &&
node.parent.parent.name.type === "JSXIdentifier" &&
isDOMElementName(node.parent.parent.name.name)
) {
// Expect a function if the attribute is like onClick={}, onclick={}, on:click={}, or
// custom events such as on-click={}.
// From the docs:
// Events are never rebound and the bindings are not reactive, as it is expensive to
// attach and detach listeners. Since event handlers are called like any other function
// each time an event fires, there is no need for reactivity; simply shortcut your handler
// if desired.
// What this means here is we actually do consider an event handler a tracked scope
// expecting a function, i.e. it's okay to use changing props/signals in the body of the
// function, even though the changes don't affect when the handler will run. This is what
// "called-function" represents—not quite a tracked scope, but a place where it's okay to
// read reactive values.
pushTrackedScope(node.expression, "called-function");
} else if (
node.parent?.type === "JSXAttribute" &&
node.parent.name.type === "JSXNamespacedName" &&
node.parent.name.namespace.name === "use" &&
isFunctionNode(node.expression)
) {
// With a `use:` hook, assume that a function passed is a called function.
pushTrackedScope(node.expression, "called-function");
} else if (
node.parent?.type === "JSXAttribute" &&
node.parent.name.name === "value" &&
node.parent.parent?.type === "JSXOpeningElement" &&
((node.parent.parent.name.type === "JSXIdentifier" &&
node.parent.parent.name.name.endsWith("Provider")) ||
(node.parent.parent.name.type === "JSXMemberExpression" &&
node.parent.parent.name.property.name === "Provider"))
) {
// From the docs: "The value passed to provider is passed to useContext as is. That means
// wrapping as a reactive expression will not work. You should pass in Signals and Stores
// directly instead of accessing them in the JSX."
// For `<SomeContext.Provider value={}>` or `<SomeProvider value={}>`, do nothing, the
// rule will warn later.
// TODO: add some kind of "anti- tracked scope" that still warns but enhances the error
// message if matched.
} else if (
node.parent?.type === "JSXAttribute" &&
node.parent.name?.type === "JSXIdentifier" &&
/^static[A-Z]/.test(node.parent.name.name) &&
node.parent.parent?.type === "JSXOpeningElement" &&
node.parent.parent.name.type === "JSXIdentifier" &&
!isDOMElementName(node.parent.parent.name.name)
) {
// A caller is passing a value to a prop prefixed with `static` in a component, i.e.
// `<Box staticName={...} />`. Since we're considering these props as static in the component
// we shouldn't allow passing reactive values to them, as this isn't just ignoring reactivity
// like initial*/default*; this is disabling it altogether as a convention. Do nothing.
} else if (
node.parent?.type === "JSXAttribute" &&
node.parent.name.name === "ref" &&
isFunctionNode(node.expression)
) {
// Callback/function refs are called when an element is created but before it is connected
// to the DOM. This is semantically a "called function", so it's fine to read reactive
// variables here.
pushTrackedScope(node.expression, "called-function");
} else if (isJSXElementOrFragment(node.parent) && isFunctionNode(node.expression)) {
pushTrackedScope(node.expression, "function"); // functions inline in JSX containers will be tracked
} else {
pushTrackedScope(node.expression, "expression");
}
} else if (node.type === "JSXSpreadAttribute") {
// allow <div {...props.nestedProps} />; {...props} is already ignored
pushTrackedScope(node.argument, "expression");
} else if (node.type === "NewExpression") {
const {
callee,
arguments: { 0: arg0 },
} = node;
if (
callee.type === "Identifier" &&
arg0 &&
// Observers from Standard Web APIs
[
"IntersectionObserver",
"MutationObserver",
"PerformanceObserver",
"ReportingObserver",
"ResizeObserver",
].includes(callee.name)
) {
// Observers callbacks are NOT tracked scopes. However, they
// don't need to react to updates to reactive variables; it's okay
// to poll the current value. Consider them called-function tracked
// scopes for our purposes.
pushTrackedScope(arg0, "called-function");
}
} else if (node.type === "CallExpression") {
if (node.callee.type === "Identifier") {
const {
callee,
arguments: { 0: arg0, 1: arg1 },
} = node;
if (
matchImport(
[
"createMemo",
"children",
"createEffect",
"createRenderEffect",
"createDeferred",
"createComputed",
"createSelector",
"untrack",
"mapArray",
"indexArray",
"observable",
],
callee.name
) ||
(matchImport("createResource", callee.name) && node.arguments.length >= 2)
) {
// createEffect, createMemo, etc. fn arg, and createResource optional
// `source` first argument may be a signal
pushTrackedScope(arg0, "function");
} else if (
matchImport(["onMount", "onCleanup", "onError"], callee.name) ||
[
// Timers
"setInterval",
"setTimeout",
"setImmediate",
"requestAnimationFrame",
"requestIdleCallback",
].includes(callee.name)
) {
// on* and timers are NOT tracked scopes. However, they
// don't need to react to updates to reactive variables; it's okay
// to poll the current value. Consider them called-function tracked
// scopes for our purposes.
pushTrackedScope(arg0, "called-function");
} else if (matchImport("on", callee.name)) {
// on accepts a signal or an array of signals as its first argument,
// and a tracking function as its second
if (arg0) {
if (arg0.type === "ArrayExpression") {
arg0.elements.forEach((element) => {
if (element && element?.type !== "SpreadElement") {
pushTrackedScope(element, "function");
}
});
} else {
pushTrackedScope(arg0, "function");
}
}
if (arg1) {
// Since dependencies are known, function can be async
pushTrackedScope(arg1, "called-function");
}
} else if (matchImport("createStore", callee.name) && arg0?.type === "ObjectExpression") {
for (const property of arg0.properties) {
if (
property.type === "Property" &&
property.kind === "get" &&
isFunctionNode(property.value)
) {
pushTrackedScope(property.value, "function");
}
}
} else if (matchImport("runWithOwner", callee.name)) {
// runWithOwner(owner, fn) only creates a tracked scope if `owner =
// getOwner()` runs in a tracked scope. If owner is a variable,
// attempt to detect if it's a tracked scope or not, but if this
// can't be done, assume it's a tracked scope.
if (arg1) {
let isTrackedScope = true;
const owner = arg0.type === "Identifier" && findVariable(context, arg0);
if (owner) {
const decl = owner.defs[0];
if (
decl &&
decl.node.type === "VariableDeclarator" &&
decl.node.init?.type === "CallExpression" &&
decl.node.init.callee.type === "Identifier" &&
matchImport("getOwner", decl.node.init.callee.name)
) {
// Check if the function in which getOwner() is called is a tracked scope. If the scopeStack
// has moved on from that scope already, assume it's tracked, since that's less intrusive.
const ownerFunction = findParent(decl.node, isProgramOrFunctionNode);
const scopeStackIndex = scopeStack.findIndex(
({ node }) => ownerFunction === node
);
if (
(scopeStackIndex >= 1 &&
!scopeStack[scopeStackIndex - 1].trackedScopes.some(
(trackedScope) =>
trackedScope.expect === "function" && trackedScope.node === ownerFunction
)) ||
scopeStackIndex === 0
) {
isTrackedScope = false;
}
}
}
if (isTrackedScope) {
pushTrackedScope(arg1, "function");
}
}
} else if (
/^(?:use|create)[A-Z]/.test(callee.name) ||
options.customReactiveFunctions.includes(callee.name)
) {
// Custom hooks parameters may or may not be tracking scopes, no way to know.
// Assume all identifier/function arguments are tracked scopes, and use "called-function"
// to allow async handlers (permissive). Assume non-resolvable args are reactive expressions.
for (const arg of node.arguments) {
permissivelyTrackNode(arg);
}
}
} else if (node.callee.type === "MemberExpression") {
const { property } = node.callee;
if (
property.type === "Identifier" &&
property.name === "addEventListener" &&
node.arguments.length >= 2
) {
// Like `on*` event handlers, mark all `addEventListener` listeners as called functions.
pushTrackedScope(node.arguments[1], "called-function");
} else if (
property.type === "Identifier" &&
(/^(?:use|create)[A-Z]/.test(property.name) ||
options.customReactiveFunctions.includes(property.name))
) {
// Handle custom hook parameters for property access custom hooks
for (const arg of node.arguments) {
permissivelyTrackNode(arg);
}
}
}
} else if (node.type === "VariableDeclarator") {
// Solid 1.3 createReactive (renamed createReaction?) returns a track
// function, a tracked scope expecting a reactive function. All of the
// track function's references where it's called push a tracked scope.
if (node.init?.type === "CallExpression" && node.init.callee.type === "Identifier") {
if (matchImport(["createReactive", "createReaction"], node.init.callee.name)) {
const track = getReturnedVar(node.id, context);
if (track) {
for (const reference of track.references) {
if (
!reference.init &&
reference.isReadOnly() &&
reference.identifier.parent?.type === "CallExpression" &&
reference.identifier.parent.callee === reference.identifier
) {
const arg0 = reference.identifier.parent.arguments[0];
if (arg0) {
pushTrackedScope(arg0, "function");
}
}
}
}
if (isFunctionNode(node.init.arguments[0])) {
pushTrackedScope(node.init.arguments[0], "called-function");
}
}
}
} else if (node.type === "AssignmentExpression") {
if (
node.left.type === "MemberExpression" &&
node.left.property.type === "Identifier" &&
isFunctionNode(node.right) &&
/^on[a-z]+$/.test(node.left.property.name)
) {
// To allow (questionable) code like the following example:
// ref.oninput = () = {
// if (!errors[ref.name]) return;
// ...
// }
// where event handlers are manually attached to refs, detect these
// scenarios and mark the right hand sides as tracked scopes expecting
// functions.
pushTrackedScope(node.right, "called-function");
}
} else if (node.type === "TaggedTemplateExpression") {
for (const expression of node.quasi.expressions) {
if (isFunctionNode(expression)) {
// ex. css`color: ${props => props.color}`. Use "called-function" to allow async handlers (permissive)
pushTrackedScope(expression, "called-function");
// exception case: add a reactive variable within checkForTrackedScopes when a param is props
for (const param of expression.params) {
if (param.type === "Identifier" && isPropsByName(param.name)) {
const variable = findVariable(context, param);
if (variable) scopeStack.pushProps(variable, currentScope().node);
}
}
}
}
}
};
return {
ImportDeclaration: handleImportDeclaration,
JSXExpressionContainer(node: T.JSXExpressionContainer) {
checkForTrackedScopes(node);
},
JSXSpreadAttribute(node: T.JSXSpreadAttribute) {
checkForTrackedScopes(node);
},
CallExpression(node: T.CallExpression) {
checkForTrackedScopes(node);
checkForSyncCallbacks(node);
// ensure calls to reactive primitives use the results.
const parent = node.parent && ignoreTransparentWrappers(node.parent, true);
if (parent?.type !== "AssignmentExpression" && parent?.type !== "VariableDeclarator") {
checkForReactiveAssignment(null, node);
}
},
NewExpression(node: T.NewExpression) {
checkForTrackedScopes(node);
},
VariableDeclarator(node: T.VariableDeclarator) {
if (node.init) {
checkForReactiveAssignment(node.id, node.init);
checkForTrackedScopes(node);
}
},
AssignmentExpression(node: T.AssignmentExpression) {
if (node.left.type !== "MemberExpression") {
checkForReactiveAssignment(node.left, node.right);
}
checkForTrackedScopes(node);
},
TaggedTemplateExpression(node: T.TaggedTemplateExpression) {
checkForTrackedScopes(node);
},
"JSXElement > JSXExpressionContainer > :function"(node: T.Node) {
if (
isFunctio