eslint-plugin-react-x
Version:
A set of composable ESLint rules for libraries and frameworks that use React as a UI runtime.
1,147 lines (1,137 loc) • 319 kB
JavaScript
import { DEFAULT_ESLINT_REACT_SETTINGS, getSettingsFromContext, toRegExp } from "@eslint-react/shared";
import { ESLintUtils } from "@typescript-eslint/utils";
import { Check, Compare, Extract, Traverse } from "@eslint-react/ast";
import * as core from "@eslint-react/core";
import { merge } from "@eslint-react/eslint";
import { AST_NODE_TYPES } from "@typescript-eslint/types";
import { isAssignmentTargetEqual, isInitializedFromReact, resolve, resolveEnclosingAssignmentTarget, resolveObjectType } from "@eslint-react/var";
import { DefinitionType, ScopeType } from "@typescript-eslint/scope-manager";
import { findVariable, getStaticValue } from "@typescript-eslint/utils/ast-utils";
import { findParentAttribute, getElementFullType, hasAttribute } from "@eslint-react/jsx";
import { compare } from "compare-versions";
import { P, isMatching, match } from "ts-pattern";
import { getConstrainedTypeAtLocation } from "@typescript-eslint/type-utils";
import { unionConstituents } from "ts-api-utils";
import "typescript";
import { simpleTraverse } from "@typescript-eslint/typescript-estree";
import { delimiterCase, snakeCase, toLowerCase } from "string-ts";
//#region \0rolldown/runtime.js
var __defProp = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};
for (var name in all) {
__defProp(target, name, {
get: all[name],
enumerable: true
});
}
if (!no_symbols) {
__defProp(target, Symbol.toStringTag, { value: "Module" });
}
return target;
};
//#endregion
//#region src/configs/disable-conflict-eslint-plugin-react.ts
var disable_conflict_eslint_plugin_react_exports = /* @__PURE__ */ __exportAll({
name: () => name$10,
rules: () => rules$9
});
const conflictingRules$1 = [
"react/button-has-type",
"react/destructuring-assignment",
"react/display-name",
"react/forbid-prop-types",
"react/forward-ref-uses-ref",
"react/hook-use-state",
"react/iframe-missing-sandbox",
"react/jsx-boolean-value",
"react/jsx-filename-extension",
"react/jsx-fragments",
"react/jsx-key",
"react/jsx-no-comment-textnodes",
"react/jsx-no-constructed-context-values",
"react/jsx-no-leaked-render",
"react/jsx-no-script-url",
"react/jsx-no-target-blank",
"react/jsx-no-useless-fragment",
"react/jsx-pascal-case",
"react/no-access-state-in-setstate",
"react/no-array-index-key",
"react/no-children-prop",
"react/no-danger",
"react/no-danger-with-children",
"react/no-deprecated",
"react/no-did-mount-set-state",
"react/no-did-update-set-state",
"react/no-direct-mutation-state",
"react/no-find-dom-node",
"react/no-namespace",
"react/no-object-type-as-default-prop",
"react/no-render-return-value",
"react/no-string-refs",
"react/no-unknown-property",
"react/no-unsafe",
"react/no-unstable-nested-components",
"react/no-unused-class-component-members",
"react/no-unused-state",
"react/no-will-update-set-state",
"react/prop-types",
"react/void-dom-elements-no-children"
];
const name$10 = "react-x/disable-conflict-eslint-plugin-react";
const rules$9 = Object.fromEntries(conflictingRules$1.map((key) => [key, "off"]));
//#endregion
//#region src/configs/disable-conflict-eslint-plugin-react-hooks.ts
var disable_conflict_eslint_plugin_react_hooks_exports = /* @__PURE__ */ __exportAll({
name: () => name$9,
rules: () => rules$8
});
const conflictingRules = [
"react-hooks/error-boundaries",
"react-hooks/exhaustive-deps",
"react-hooks/globals",
"react-hooks/immutability",
"react-hooks/purity",
"react-hooks/refs",
"react-hooks/rules-of-hooks",
"react-hooks/set-state-in-effect",
"react-hooks/set-state-in-render",
"react-hooks/static-components",
"react-hooks/unsupported-syntax",
"react-hooks/use-memo"
];
const name$9 = "react-x/disable-conflict-eslint-plugin-react-hooks";
const rules$8 = Object.fromEntries(conflictingRules.map((key) => [key, "off"]));
//#endregion
//#region src/configs/disable-experimental.ts
var disable_experimental_exports = /* @__PURE__ */ __exportAll({
name: () => name$8,
rules: () => rules$7
});
const name$8 = "react-x/disable-experimental";
const rules$7 = {
"react-x/globals": "off",
"react-x/immutability": "off",
"react-x/no-duplicate-key": "off",
"react-x/no-implicit-children": "off",
"react-x/no-implicit-key": "off",
"react-x/no-implicit-ref": "off",
"react-x/no-misused-capture-owner-stack": "off",
"react-x/no-unused-props": "off",
"react-x/no-unused-state": "off",
"react-x/refs": "off",
"react-x/set-state-in-render": "off"
};
//#endregion
//#region src/configs/disable-type-checked.ts
var disable_type_checked_exports = /* @__PURE__ */ __exportAll({
name: () => name$7,
rules: () => rules$6
});
const name$7 = "react-x/disable-type-checked";
const rules$6 = {
"react-x/no-implicit-children": "off",
"react-x/no-implicit-key": "off",
"react-x/no-implicit-ref": "off",
"react-x/no-leaked-conditional-rendering": "off",
"react-x/no-unused-props": "off"
};
//#endregion
//#region package.json
var name$6 = "eslint-plugin-react-x";
var version = "5.18.4";
//#endregion
//#region src/utils/create-rule.ts
function getDocsUrl(ruleName) {
return `https://eslint-react.xyz/docs/rules/${ruleName}`;
}
const createRule = ESLintUtils.RuleCreator(getDocsUrl);
//#endregion
//#region src/rules/error-boundaries/error-boundaries.ts
const RULE_NAME$50 = "error-boundaries";
var error_boundaries_default = createRule({
meta: {
type: "problem",
docs: { description: "Validates usage of Error Boundaries instead of try/catch for errors in child components." },
messages: {
tryCatchWithJsx: "Use an Error Boundary to catch errors in child components. Try/catch can't catch errors during React's rendering process.",
tryCatchWithUse: "Use an Error Boundary instead of try/catch around the 'use' hook. The 'use' hook suspends the component, and its errors can only be caught by Error Boundaries."
},
schema: []
},
name: RULE_NAME$50,
create: create$50,
defaultOptions: []
});
function create$50(context) {
if (!context.sourceCode.text.includes("try")) return {};
const hint = core.JsxDetectionHint.DoNotIncludeJsxWithNullValue | core.JsxDetectionHint.DoNotIncludeJsxWithNumberValue | core.JsxDetectionHint.DoNotIncludeJsxWithBigIntValue | core.JsxDetectionHint.DoNotIncludeJsxWithStringValue | core.JsxDetectionHint.DoNotIncludeJsxWithBooleanValue | core.JsxDetectionHint.DoNotIncludeJsxWithUndefinedValue | core.JsxDetectionHint.DoNotIncludeJsxWithEmptyArrayValue;
const fc = core.getFunctionComponentCollector(context);
const hc = core.getHookCollector(context);
const reported = /* @__PURE__ */ new Set();
const useCalls = /* @__PURE__ */ new Set();
return merge(fc.visitor, hc.visitor, {
CallExpression(node) {
if (!core.isUseCall(context, node)) return;
useCalls.add(node);
},
"Program:exit"(node) {
const comps = fc.api.getAllComponents(node);
const hooks = hc.api.getAllHooks(node);
const funcs = [...comps, ...hooks];
for (const call of useCalls) {
const stmt = Traverse.findEnclosingTryBlock(call);
const func = Traverse.findParent(stmt, (n) => funcs.some((f) => f.node === n));
if (stmt != null && func != null && !reported.has(stmt)) {
context.report({
messageId: "tryCatchWithUse",
node: stmt
});
reported.add(stmt);
}
}
for (const { rets } of funcs) for (const ret of rets) {
if (ret == null) continue;
if (!core.isJsxLike(context, ret, hint)) continue;
const stmt = Traverse.findEnclosingTryBlock(ret);
if (stmt != null && !reported.has(stmt)) {
context.report({
messageId: "tryCatchWithJsx",
node: stmt
});
reported.add(stmt);
}
}
}
});
}
//#endregion
//#region src/rules/exhaustive-deps/exhaustive-deps.ts
const rule$1 = {
meta: {
type: "suggestion",
docs: {
description: "Verifies the list of dependencies for Hooks like 'useEffect' and similar.",
recommended: true,
url: "https://github.com/facebook/react/issues/14920"
},
fixable: "code",
hasSuggestions: true,
schema: [{
type: "object",
additionalProperties: false,
enableDangerousAutofixThisMayCauseInfiniteLoops: false,
properties: {
additionalHooks: { type: "string" },
enableDangerousAutofixThisMayCauseInfiniteLoops: { type: "boolean" },
experimental_autoDependenciesHooks: {
type: "array",
items: { type: "string" }
},
requireExplicitEffectDeps: { type: "boolean" }
}
}]
},
create(context) {
const rawOptions = context.options && context.options[0];
const additionalHooks = rawOptions && rawOptions.additionalHooks ? new RegExp(rawOptions.additionalHooks) : getSettingsFromContext(context).additionalEffectHooks;
const enableDangerousAutofixThisMayCauseInfiniteLoops = rawOptions && rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops || false;
const options = {
additionalHooks,
experimental_autoDependenciesHooks: rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks) ? rawOptions.experimental_autoDependenciesHooks : [],
enableDangerousAutofixThisMayCauseInfiniteLoops,
requireExplicitEffectDeps: rawOptions && rawOptions.requireExplicitEffectDeps || false
};
function reportProblem(problem) {
if (enableDangerousAutofixThisMayCauseInfiniteLoops) {
if (Array.isArray(problem.suggest) && problem.suggest.length > 0 && problem.suggest[0]) problem.fix = problem.suggest[0].fix;
}
context.report(problem);
}
/**
* SourceCode that also works down to ESLint 3.0.0
*/
const getSourceCode = typeof context.getSourceCode === "function" ? () => {
return context.getSourceCode();
} : () => {
return context.sourceCode;
};
/**
* SourceCode#getScope that also works down to ESLint 3.0.0
*/
const getScope = typeof context.getScope === "function" ? () => {
return context.getScope();
} : (node) => {
return context.sourceCode.getScope(node);
};
const scopeManager = getSourceCode().scopeManager;
const setStateCallSites = /* @__PURE__ */ new WeakMap();
const stateVariables = /* @__PURE__ */ new WeakSet();
const stableKnownValueCache = /* @__PURE__ */ new WeakMap();
const functionWithoutCapturedValueCache = /* @__PURE__ */ new WeakMap();
const useEffectEventVariables = /* @__PURE__ */ new WeakSet();
function memoizeWithWeakMap(fn, map) {
return function(arg) {
if (map.has(arg)) return map.get(arg);
const result = fn(arg);
map.set(arg, result);
return result;
};
}
/**
* Visitor for both function expressions and arrow function expressions.
*/
function visitFunctionWithDependencies(node, declaredDependenciesNode, reactiveHook, reactiveHookName, isEffect, isAutoDepsHook) {
if (isEffect && node.async) reportProblem({
node,
message: "Effect callbacks are synchronous to prevent race conditions. Put the async function inside:\n\nuseEffect(() => {\n async function fetchData() {\n // You can await here\n const response = await MyAPI.getData(someId);\n // ...\n }\n fetchData();\n}, [someId]); // Or [] if effect doesn't need props or state\n\nLearn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching"
});
const scope = scopeManager.acquire(node);
if (!scope) throw new Error("Unable to acquire scope for the current node. This is a bug in eslint-plugin-react-hooks, please file an issue.");
const pureScopes = /* @__PURE__ */ new Set();
let componentScope = null;
{
let currentScope = scope.upper;
while (currentScope) {
pureScopes.add(currentScope);
if (currentScope.type === "function" || currentScope.type === "hook" || currentScope.type === "component") break;
currentScope = currentScope.upper;
}
if (!currentScope) return;
componentScope = currentScope;
}
const isArray = Array.isArray;
function isStableKnownHookValue(resolved) {
if (!isArray(resolved.defs)) return false;
const def = resolved.defs[0];
if (def == null) return false;
const defNode = def.node;
if (defNode.type !== "VariableDeclarator") return false;
let init = defNode.init;
if (init == null) return false;
while (init.type === "TSAsExpression" || init.type === "AsExpression") init = init.expression;
let declaration = defNode.parent;
if (declaration == null && componentScope != null) {
fastFindReferenceWithParent(componentScope.block, def.node.id);
declaration = def.node.parent;
if (declaration == null) return false;
}
if (declaration != null && "kind" in declaration && declaration.kind === "const" && init.type === "Literal" && (typeof init.value === "string" || typeof init.value === "number" || init.value === null)) return true;
if (init.type !== "CallExpression") return false;
let callee = init.callee;
if (callee.type === "MemberExpression" && "name" in callee.object && callee.object.name === "React" && callee.property != null && !callee.computed) callee = callee.property;
if (callee.type !== "Identifier") return false;
const id = def.node.id;
const { name } = callee;
if (name === "useRef" && id.type === "Identifier") return true;
else if (isUseEffectEventIdentifier$1(callee) && id.type === "Identifier") {
for (const ref of resolved.references) if (ref !== id) useEffectEventVariables.add(ref.identifier);
return true;
} else if (name === "useState" || name === "useReducer" || name === "useActionState") {
if (id.type === "ArrayPattern" && id.elements.length === 2 && isArray(resolved.identifiers)) {
if (id.elements[1] === resolved.identifiers[0]) {
if (name === "useState") {
const references = resolved.references;
let writeCount = 0;
for (const reference of references) {
if (reference.isWrite()) writeCount++;
if (writeCount > 1) return false;
setStateCallSites.set(reference.identifier, id.elements[0]);
}
}
return true;
} else if (id.elements[0] === resolved.identifiers[0]) {
if (name === "useState") {
const references = resolved.references;
for (const reference of references) stateVariables.add(reference.identifier);
}
return false;
}
}
} else if (name === "useTransition") {
if (id.type === "ArrayPattern" && id.elements.length === 2 && Array.isArray(resolved.identifiers)) {
if (id.elements[1] === resolved.identifiers[0]) return true;
}
}
return false;
}
function isFunctionWithoutCapturedValues(resolved) {
if (!isArray(resolved.defs)) return false;
const def = resolved.defs[0];
if (def == null) return false;
if (def.node == null || def.node.id == null) return false;
const fnNode = def.node;
const childScopes = componentScope?.childScopes || [];
let fnScope = null;
for (const childScope of childScopes) {
const childScopeBlock = childScope.block;
if (fnNode.type === "FunctionDeclaration" && childScopeBlock === fnNode || fnNode.type === "VariableDeclarator" && childScopeBlock.parent === fnNode) {
fnScope = childScope;
break;
}
}
if (fnScope == null) return false;
for (const ref of fnScope.through) {
if (ref.resolved == null) continue;
if (pureScopes.has(ref.resolved.scope) && !memoizedIsStableKnownHookValue(ref.resolved)) return false;
}
return true;
}
const memoizedIsStableKnownHookValue = memoizeWithWeakMap(isStableKnownHookValue, stableKnownValueCache);
const memoizedIsFunctionWithoutCapturedValues = memoizeWithWeakMap(isFunctionWithoutCapturedValues, functionWithoutCapturedValueCache);
const currentRefsInEffectCleanup = /* @__PURE__ */ new Map();
function isInsideEffectCleanup(reference) {
let curScope = reference.from;
let isInReturnedFunction = false;
while (curScope != null && curScope.block !== node) {
if (curScope.type === "function") isInReturnedFunction = curScope.block.parent != null && curScope.block.parent.type === "ReturnStatement";
curScope = curScope.upper;
}
return isInReturnedFunction;
}
const dependencies = /* @__PURE__ */ new Map();
const optionalChains = /* @__PURE__ */ new Map();
gatherDependenciesRecursively(scope);
function gatherDependenciesRecursively(currentScope) {
for (const reference of currentScope.references) {
if (!reference.resolved) continue;
if (!pureScopes.has(reference.resolved.scope)) continue;
const referenceNode = fastFindReferenceWithParent(node, reference.identifier);
if (referenceNode == null) continue;
const dependencyNode = getDependency(referenceNode);
const dependency = analyzePropertyChain(dependencyNode, optionalChains);
if (isEffect && dependencyNode.type === "Identifier" && (dependencyNode.parent?.type === "MemberExpression" || dependencyNode.parent?.type === "OptionalMemberExpression") && !dependencyNode.parent.computed && dependencyNode.parent.property.type === "Identifier" && dependencyNode.parent.property.name === "current" && isInsideEffectCleanup(reference)) currentRefsInEffectCleanup.set(dependency, {
reference,
dependencyNode
});
if (dependencyNode.parent?.type === "TSTypeQuery" || dependencyNode.parent?.type === "TSTypeReference") continue;
const def = reference.resolved.defs[0];
if (def == null) continue;
if (def.node != null && def.node.init === node.parent) continue;
if (def.type === "TypeParameter" || dependencyNode.parent?.type === "GenericTypeAnnotation") continue;
if (!dependencies.has(dependency)) {
const resolved = reference.resolved;
const isStable = memoizedIsStableKnownHookValue(resolved) || memoizedIsFunctionWithoutCapturedValues(resolved);
dependencies.set(dependency, {
isStable,
references: [reference]
});
} else dependencies.get(dependency)?.references.push(reference);
}
for (const childScope of currentScope.childScopes) gatherDependenciesRecursively(childScope);
}
currentRefsInEffectCleanup.forEach(({ reference, dependencyNode }, dependency) => {
const references = reference.resolved?.references || [];
let foundCurrentAssignment = false;
for (const ref of references) {
const { identifier } = ref;
const { parent } = identifier;
if (parent != null && parent.type === "MemberExpression" && !parent.computed && parent.property.type === "Identifier" && parent.property.name === "current" && parent.parent?.type === "AssignmentExpression" && parent.parent.left === parent) {
foundCurrentAssignment = true;
break;
}
}
if (foundCurrentAssignment) return;
reportProblem({
node: dependencyNode.parent.property,
message: `The ref value '${dependency}.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy '${dependency}.current' to a variable inside the effect, and use that variable in the cleanup function.`
});
});
const staleAssignments = /* @__PURE__ */ new Set();
function reportStaleAssignment(writeExpr, key) {
if (staleAssignments.has(key)) return;
staleAssignments.add(key);
reportProblem({
node: writeExpr,
message: `Assignments to the '${key}' variable from inside React Hook ${getSourceCode().getText(reactiveHook)} will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the '.current' property. Otherwise, you can move this variable directly inside ${getSourceCode().getText(reactiveHook)}.`
});
}
const stableDependencies = /* @__PURE__ */ new Set();
dependencies.forEach(({ isStable, references }, key) => {
if (isStable) stableDependencies.add(key);
references.forEach((reference) => {
if (reference.writeExpr) reportStaleAssignment(reference.writeExpr, key);
});
});
if (staleAssignments.size > 0) return;
if (!declaredDependenciesNode) {
if (isAutoDepsHook) return;
let setStateInsideEffectWithoutDeps = null;
dependencies.forEach(({ references }, key) => {
if (setStateInsideEffectWithoutDeps) return;
references.forEach((reference) => {
if (setStateInsideEffectWithoutDeps) return;
const id = reference.identifier;
if (!setStateCallSites.has(id)) return;
let fnScope = reference.from;
while (fnScope != null && fnScope.type !== "function") fnScope = fnScope.upper;
if (fnScope?.block === node) setStateInsideEffectWithoutDeps = key;
});
});
if (setStateInsideEffectWithoutDeps) {
const { suggestedDependencies } = collectRecommendations({
dependencies,
declaredDependencies: [],
stableDependencies,
externalDependencies: /* @__PURE__ */ new Set(),
isEffect: true
});
reportProblem({
node: reactiveHook,
message: `React Hook ${reactiveHookName} contains a call to '${setStateInsideEffectWithoutDeps}'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [` + suggestedDependencies.join(", ") + `] as a second argument to the ${reactiveHookName} Hook.`,
suggest: [{
desc: `Add dependencies array: [${suggestedDependencies.join(", ")}]`,
fix(fixer) {
return fixer.insertTextAfter(node, `, [${suggestedDependencies.join(", ")}]`);
}
}]
});
}
return;
}
if (isAutoDepsHook && declaredDependenciesNode.type === "Literal" && declaredDependenciesNode.value === null) return;
const declaredDependencies = [];
const externalDependencies = /* @__PURE__ */ new Set();
const isArrayExpression = declaredDependenciesNode.type === "ArrayExpression";
const isTSAsArrayExpression = declaredDependenciesNode.type === "TSAsExpression" && declaredDependenciesNode.expression.type === "ArrayExpression";
if (!isArrayExpression && !isTSAsArrayExpression) reportProblem({
node: declaredDependenciesNode,
message: `React Hook ${getSourceCode().getText(reactiveHook)} was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies.`
});
else (isTSAsArrayExpression ? declaredDependenciesNode.expression : declaredDependenciesNode).elements.forEach((declaredDependencyNode) => {
if (declaredDependencyNode === null) return;
if (declaredDependencyNode.type === "SpreadElement") {
reportProblem({
node: declaredDependencyNode,
message: `React Hook ${getSourceCode().getText(reactiveHook)} has a spread element in its dependency array. This means we can't statically verify whether you've passed the correct dependencies.`
});
return;
}
if (useEffectEventVariables.has(declaredDependencyNode)) reportProblem({
node: declaredDependencyNode,
message: `Functions returned from \`useEffectEvent\` must not be included in the dependency array. Remove \`${getSourceCode().getText(declaredDependencyNode)}\` from the list.`,
suggest: [{
desc: `Remove the dependency \`${getSourceCode().getText(declaredDependencyNode)}\``,
fix(fixer) {
return fixer.removeRange(declaredDependencyNode.range);
}
}]
});
let declaredDependency;
try {
declaredDependency = analyzePropertyChain(declaredDependencyNode, null);
} catch (error) {
if (error instanceof Error && /Unsupported node type/.test(error.message)) {
if (declaredDependencyNode.type === "Literal") {
if (declaredDependencyNode.value && dependencies.has(declaredDependencyNode.value)) reportProblem({
node: declaredDependencyNode,
message: `The ${declaredDependencyNode.raw} literal is not a valid dependency because it never changes. Did you mean to include ${declaredDependencyNode.value} in the array instead?`
});
else reportProblem({
node: declaredDependencyNode,
message: `The ${declaredDependencyNode.raw} literal is not a valid dependency because it never changes. You can safely remove it.`
});
} else reportProblem({
node: declaredDependencyNode,
message: `React Hook ${getSourceCode().getText(reactiveHook)} has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked.`
});
return;
} else throw error;
}
let maybeID = declaredDependencyNode;
while (maybeID.type === "MemberExpression" || maybeID.type === "OptionalMemberExpression" || maybeID.type === "ChainExpression") maybeID = maybeID.object || maybeID.expression.object;
const isDeclaredInComponent = !componentScope.through.some((ref) => ref.identifier === maybeID);
declaredDependencies.push({
key: declaredDependency,
node: declaredDependencyNode
});
if (!isDeclaredInComponent) externalDependencies.add(declaredDependency);
});
const { suggestedDependencies, unnecessaryDependencies, missingDependencies, duplicateDependencies } = collectRecommendations({
dependencies,
declaredDependencies,
stableDependencies,
externalDependencies,
isEffect
});
let suggestedDeps = suggestedDependencies;
if (duplicateDependencies.size + missingDependencies.size + unnecessaryDependencies.size === 0) {
scanForConstructions({
declaredDependencies,
declaredDependenciesNode,
componentScope,
scope
}).forEach(({ construction, isUsedOutsideOfHook, depType }) => {
const wrapperHook = depType === "function" ? "useCallback" : "useMemo";
const constructionType = depType === "function" ? "definition" : "initialization";
const defaultAdvice = `wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`;
const advice = isUsedOutsideOfHook ? `To fix this, ${defaultAdvice}` : `Move it inside the ${reactiveHookName} callback. Alternatively, ${defaultAdvice}`;
const causation = depType === "conditional" || depType === "logical expression" ? "could make" : "makes";
const message = `The '${construction.name.name}' ${depType} ${causation} the dependencies of ${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc?.start.line}) change on every render. ${advice}`;
let suggest;
if (isUsedOutsideOfHook && construction.type === "Variable" && depType === "function") suggest = [{
desc: `Wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`,
fix(fixer) {
const [before, after] = wrapperHook === "useMemo" ? [`useMemo(() => { return `, "; })"] : ["useCallback(", ")"];
return [fixer.insertTextBefore(construction.node.init, before), fixer.insertTextAfter(construction.node.init, after)];
}
}];
reportProblem({
node: construction.node,
message,
suggest
});
});
return;
}
if (!isEffect && missingDependencies.size > 0) suggestedDeps = collectRecommendations({
dependencies,
declaredDependencies: [],
stableDependencies,
externalDependencies,
isEffect
}).suggestedDependencies;
function areDeclaredDepsAlphabetized() {
if (declaredDependencies.length === 0) return true;
const declaredDepKeys = declaredDependencies.map((dep) => dep.key);
const sortedDeclaredDepKeys = declaredDepKeys.slice().sort();
return declaredDepKeys.join(",") === sortedDeclaredDepKeys.join(",");
}
if (areDeclaredDepsAlphabetized()) suggestedDeps.sort();
function formatDependency(path) {
const members = path.split(".");
let finalPath = "";
for (let i = 0; i < members.length; i++) {
if (i !== 0) {
const pathSoFar = members.slice(0, i + 1).join(".");
const isOptional = optionalChains.get(pathSoFar) === true;
finalPath += isOptional ? "?." : ".";
}
finalPath += members[i];
}
return finalPath;
}
function getWarningMessage(deps, singlePrefix, label, fixVerb) {
if (deps.size === 0) return null;
return (deps.size > 1 ? "" : singlePrefix + " ") + label + " " + (deps.size > 1 ? "dependencies" : "dependency") + ": " + joinEnglish(Array.from(deps).sort().map((name) => "'" + formatDependency(name) + "'")) + `. Either ${fixVerb} ${deps.size > 1 ? "them" : "it"} or remove the dependency array.`;
}
let extraWarning = "";
if (unnecessaryDependencies.size > 0) {
let badRef = null;
Array.from(unnecessaryDependencies.keys()).forEach((key) => {
if (badRef !== null) return;
if (key.endsWith(".current")) badRef = key;
});
if (badRef !== null) extraWarning = ` Mutable values like '${badRef}' aren't valid dependencies because mutating them doesn't re-render the component.`;
else if (externalDependencies.size > 0) {
const dep = Array.from(externalDependencies)[0];
if (!scope.set.has(dep)) extraWarning = ` Outer scope values like '${dep}' aren't valid dependencies because mutating them doesn't re-render the component.`;
}
}
if (!extraWarning && missingDependencies.has("props")) {
const propDep = dependencies.get("props");
if (propDep == null) return;
const refs = propDep.references;
if (!Array.isArray(refs)) return;
let isPropsOnlyUsedInMembers = true;
for (const ref of refs) {
const id = fastFindReferenceWithParent(componentScope.block, ref.identifier);
if (!id) {
isPropsOnlyUsedInMembers = false;
break;
}
const parent = id.parent;
if (parent == null) {
isPropsOnlyUsedInMembers = false;
break;
}
if (parent.type !== "MemberExpression" && parent.type !== "OptionalMemberExpression") {
isPropsOnlyUsedInMembers = false;
break;
}
}
if (isPropsOnlyUsedInMembers) extraWarning = ` However, 'props' will change when *any* prop changes, so the preferred fix is to destructure the 'props' object outside of the ${reactiveHookName} call and refer to those specific props inside ${getSourceCode().getText(reactiveHook)}.`;
}
if (!extraWarning && missingDependencies.size > 0) {
let missingCallbackDep = null;
missingDependencies.forEach((missingDep) => {
if (missingCallbackDep) return;
const topScopeRef = componentScope.set.get(missingDep);
const usedDep = dependencies.get(missingDep);
if (!usedDep?.references || usedDep?.references[0]?.resolved !== topScopeRef) return;
const def = topScopeRef?.defs[0];
if (def == null || def.name == null || def.type !== "Parameter") return;
let isFunctionCall = false;
let id;
for (const reference of usedDep.references) {
id = reference.identifier;
if (id != null && id.parent != null && (id.parent.type === "CallExpression" || id.parent.type === "OptionalCallExpression") && id.parent.callee === id) {
isFunctionCall = true;
break;
}
}
if (!isFunctionCall) return;
missingCallbackDep = missingDep;
});
if (missingCallbackDep !== null) extraWarning = ` If '${missingCallbackDep}' changes too often, find the parent component that defines it and wrap that definition in useCallback.`;
}
if (!extraWarning && missingDependencies.size > 0) {
let setStateRecommendation = null;
for (const missingDep of missingDependencies) {
if (setStateRecommendation !== null) break;
const references = dependencies.get(missingDep).references;
let id;
let maybeCall;
for (const reference of references) {
id = reference.identifier;
maybeCall = id.parent;
while (maybeCall != null && maybeCall !== componentScope.block) {
if (maybeCall.type === "CallExpression") {
const correspondingStateVariable = setStateCallSites.get(maybeCall.callee);
if (correspondingStateVariable != null) {
if ("name" in correspondingStateVariable && correspondingStateVariable.name === missingDep) setStateRecommendation = {
missingDep,
setter: "name" in maybeCall.callee ? maybeCall.callee.name : "",
form: "updater"
};
else if (stateVariables.has(id)) setStateRecommendation = {
missingDep,
setter: "name" in maybeCall.callee ? maybeCall.callee.name : "",
form: "reducer"
};
else {
const resolved = reference.resolved;
if (resolved != null) {
const def = resolved.defs[0];
if (def != null && def.type === "Parameter") setStateRecommendation = {
missingDep,
setter: "name" in maybeCall.callee ? maybeCall.callee.name : "",
form: "inlineReducer"
};
}
}
break;
}
}
maybeCall = maybeCall.parent;
}
if (setStateRecommendation !== null) break;
}
}
if (setStateRecommendation !== null) switch (setStateRecommendation.form) {
case "reducer":
extraWarning = ` You can also replace multiple useState variables with useReducer if '${setStateRecommendation.setter}' needs the current value of '${setStateRecommendation.missingDep}'.`;
break;
case "inlineReducer":
extraWarning = ` If '${setStateRecommendation.setter}' needs the current value of '${setStateRecommendation.missingDep}', you can also switch to useReducer instead of useState and read '${setStateRecommendation.missingDep}' in the reducer.`;
break;
case "updater":
extraWarning = ` You can also do a functional update '${setStateRecommendation.setter}(${setStateRecommendation.missingDep.slice(0, 1)} => ...)' if you only need '${setStateRecommendation.missingDep}' in the '${setStateRecommendation.setter}' call.`;
break;
default: throw new Error("Unknown case.");
}
}
reportProblem({
node: declaredDependenciesNode,
message: `React Hook ${getSourceCode().getText(reactiveHook)} has ` + (getWarningMessage(missingDependencies, "a", "missing", "include") || getWarningMessage(unnecessaryDependencies, "an", "unnecessary", "exclude") || getWarningMessage(duplicateDependencies, "a", "duplicate", "omit")) + extraWarning,
suggest: [{
desc: `Update the dependencies array to be: [${suggestedDeps.map(formatDependency).join(", ")}]`,
fix(fixer) {
return fixer.replaceText(declaredDependenciesNode, `[${suggestedDeps.map(formatDependency).join(", ")}]`);
}
}]
});
}
function visitCallExpression(node) {
const callbackIndex = getReactiveHookCallbackIndex(node.callee, options);
if (callbackIndex === -1) return;
let callback = node.arguments[callbackIndex];
const reactiveHook = node.callee;
const nodeWithoutNamespace = getNodeWithoutReactNamespace$1(reactiveHook);
const reactiveHookName = "name" in nodeWithoutNamespace ? nodeWithoutNamespace.name : "";
const maybeNode = node.arguments[callbackIndex + 1];
const declaredDependenciesNode = maybeNode && !(maybeNode.type === "Identifier" && maybeNode.name === "undefined") ? maybeNode : void 0;
const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName);
if (!callback) {
reportProblem({
node: reactiveHook,
message: `React Hook ${reactiveHookName} requires an effect callback. Did you forget to pass a callback to the hook?`
});
return;
}
if (!maybeNode && isEffect && options.requireExplicitEffectDeps) reportProblem({
node: reactiveHook,
message: `React Hook ${reactiveHookName} always requires dependencies. Please add a dependency array or an explicit \`undefined\``
});
const isAutoDepsHook = options.experimental_autoDependenciesHooks.includes(reactiveHookName);
if ((!declaredDependenciesNode || isAutoDepsHook && declaredDependenciesNode.type === "Literal" && declaredDependenciesNode.value === null) && !isEffect) {
if (reactiveHookName === "useMemo" || reactiveHookName === "useCallback") reportProblem({
node: reactiveHook,
message: `React Hook ${reactiveHookName} does nothing when called with only one argument. Did you forget to pass an array of dependencies?`
});
return;
}
while (callback.type === "TSAsExpression" || callback.type === "AsExpression") callback = callback.expression;
switch (callback.type) {
case "FunctionExpression":
case "ArrowFunctionExpression":
visitFunctionWithDependencies(callback, declaredDependenciesNode, reactiveHook, reactiveHookName, isEffect, isAutoDepsHook);
return;
case "Identifier":
if (!declaredDependenciesNode || isAutoDepsHook && declaredDependenciesNode.type === "Literal" && declaredDependenciesNode.value === null) return;
if ("elements" in declaredDependenciesNode && declaredDependenciesNode.elements && declaredDependenciesNode.elements.some((el) => el && el.type === "Identifier" && el.name === callback.name)) return;
const variable = getScope(callback).set.get(callback.name);
if (variable == null || variable.defs == null) return;
const def = variable.defs[0];
if (!def || !def.node) break;
if (def.type === "Parameter") {
reportProblem({
node: reactiveHook,
message: getUnknownDependenciesMessage(reactiveHookName)
});
return;
}
if (def.type !== "Variable" && def.type !== "FunctionName") break;
switch (def.node.type) {
case "FunctionDeclaration":
visitFunctionWithDependencies(def.node, declaredDependenciesNode, reactiveHook, reactiveHookName, isEffect, isAutoDepsHook);
return;
case "VariableDeclarator":
const init = def.node.init;
if (!init) break;
switch (init.type) {
case "ArrowFunctionExpression":
case "FunctionExpression":
visitFunctionWithDependencies(init, declaredDependenciesNode, reactiveHook, reactiveHookName, isEffect, isAutoDepsHook);
return;
}
}
break;
default:
reportProblem({
node: reactiveHook,
message: getUnknownDependenciesMessage(reactiveHookName)
});
return;
}
reportProblem({
node: reactiveHook,
message: `React Hook ${reactiveHookName} has a missing dependency: '${callback.name}'. Either include it or remove the dependency array.`,
suggest: [{
desc: `Update the dependencies array to be: [${callback.name}]`,
fix(fixer) {
return fixer.replaceText(declaredDependenciesNode, `[${callback.name}]`);
}
}]
});
}
return { CallExpression: visitCallExpression };
}
};
function collectRecommendations({ dependencies, declaredDependencies, stableDependencies, externalDependencies, isEffect }) {
const depTree = createDepTree();
function createDepTree() {
return {
isUsed: false,
isSatisfiedRecursively: false,
isSubtreeUsed: false,
children: /* @__PURE__ */ new Map()
};
}
dependencies.forEach((_, key) => {
const node = getOrCreateNodeByPath(depTree, key);
node.isUsed = true;
markAllParentsByPath(depTree, key, (parent) => {
parent.isSubtreeUsed = true;
});
});
declaredDependencies.forEach(({ key }) => {
const node = getOrCreateNodeByPath(depTree, key);
node.isSatisfiedRecursively = true;
});
stableDependencies.forEach((key) => {
const node = getOrCreateNodeByPath(depTree, key);
node.isSatisfiedRecursively = true;
});
function getOrCreateNodeByPath(rootNode, path) {
const keys = path.split(".");
let node = rootNode;
for (const key of keys) {
let child = node.children.get(key);
if (!child) {
child = createDepTree();
node.children.set(key, child);
}
node = child;
}
return node;
}
function markAllParentsByPath(rootNode, path, fn) {
const keys = path.split(".");
let node = rootNode;
for (const key of keys) {
const child = node.children.get(key);
if (!child) return;
fn(child);
node = child;
}
}
const missingDependencies = /* @__PURE__ */ new Set();
const satisfyingDependencies = /* @__PURE__ */ new Set();
scanTreeRecursively(depTree, missingDependencies, satisfyingDependencies, (key) => key);
function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
node.children.forEach((child, key) => {
const path = keyToPath(key);
if (child.isSatisfiedRecursively) {
if (child.isSubtreeUsed) satisfyingPaths.add(path);
return;
}
if (child.isUsed) {
missingPaths.add(path);
return;
}
scanTreeRecursively(child, missingPaths, satisfyingPaths, (childKey) => path + "." + childKey);
});
}
const suggestedDependencies = [];
const unnecessaryDependencies = /* @__PURE__ */ new Set();
const duplicateDependencies = /* @__PURE__ */ new Set();
declaredDependencies.forEach(({ key }) => {
if (satisfyingDependencies.has(key)) {
if (suggestedDependencies.indexOf(key) === -1) suggestedDependencies.push(key);
else duplicateDependencies.add(key);
} else if (isEffect && !key.endsWith(".current") && !externalDependencies.has(key)) {
if (suggestedDependencies.indexOf(key) === -1) suggestedDependencies.push(key);
} else unnecessaryDependencies.add(key);
});
missingDependencies.forEach((key) => {
suggestedDependencies.push(key);
});
return {
suggestedDependencies,
unnecessaryDependencies,
duplicateDependencies,
missingDependencies
};
}
function getConstructionExpressionType(node) {
switch (node.type) {
case "ObjectExpression": return "object";
case "ArrayExpression": return "array";
case "ArrowFunctionExpression":
case "FunctionExpression": return "function";
case "ClassExpression": return "class";
case "ConditionalExpression":
if (getConstructionExpressionType(node.consequent) != null || getConstructionExpressionType(node.alternate) != null) return "conditional";
return null;
case "LogicalExpression":
if (getConstructionExpressionType(node.left) != null || getConstructionExpressionType(node.right) != null) return "logical expression";
return null;
case "JSXFragment": return "JSX fragment";
case "JSXElement": return "JSX element";
case "AssignmentExpression":
if (getConstructionExpressionType(node.right) != null) return "assignment expression";
return null;
case "NewExpression": return "object construction";
case "Literal":
if (node.value instanceof RegExp) return "regular expression";
return null;
case "TypeCastExpression":
case "AsExpression":
case "TSAsExpression": return getConstructionExpressionType(node.expression);
}
return null;
}
function scanForConstructions({ declaredDependencies, declaredDependenciesNode, componentScope, scope }) {
const constructions = declaredDependencies.map(({ key }) => {
const ref = componentScope.variables.find((v) => v.name === key);
if (ref == null) return null;
const node = ref.defs[0];
if (node == null) return null;
if (node.type === "Variable" && node.node.type === "VariableDeclarator" && node.node.id.type === "Identifier" && node.node.init != null) {
const constantExpressionType = getConstructionExpressionType(node.node.init);
if (constantExpressionType) return [ref, constantExpressionType];
}
if (node.type === "FunctionName" && node.node.type === "FunctionDeclaration") return [ref, "function"];
if (node.type === "ClassName" && node.node.type === "ClassDeclaration") return [ref, "class"];
return null;
}).filter(Boolean);
function isUsedOutsideOfHook(ref) {
let foundWriteExpr = false;
for (const reference of ref.references) {
if (reference.writeExpr) {
if (foundWriteExpr) return true;
else {
foundWriteExpr = true;
continue;
}
}
let currentScope = reference.from;
while (currentScope !== scope && currentScope != null) currentScope = currentScope.upper;
if (currentScope !== scope) {
if (!isAncestorNodeOf(declaredDependenciesNode, reference.identifier)) return true;
}
}
return false;
}
return constructions.map(([ref, depType]) => ({
construction: ref.defs[0],
depType,
isUsedOutsideOfHook: isUsedOutsideOfHook(ref)
}));
}
/**
* Assuming () means the passed/returned node:
* (props) => (props)
* props.(foo) => (props.foo)
* props.foo.(bar) => (props).foo.bar
* props.foo.bar.(baz) => (props).foo.bar.baz
*/
function getDependency(node) {
if (node.parent && (node.parent.type === "MemberExpression" || node.parent.type === "OptionalMemberExpression") && node.parent.object === node && "name" in node.parent.property && node.parent.property.name !== "current" && !node.parent.computed && !(node.parent.parent != null && (node.parent.parent.type === "CallExpression" || node.parent.parent.type === "OptionalCallExpression") && node.parent.parent.callee === node.parent)) return getDependency(node.parent);
else if (node.type === "MemberExpression" && node.parent && node.parent.type === "AssignmentExpression" && node.parent.left === node) return node.object;
else return node;
}
/**
* Mark a node as either optional or required.
* Note: If the node argument is an OptionalMemberExpression, it doesn't necessarily mean it is optional.
* It just means there is an optional member somewhere inside.
* This particular node might still represent a required member, so check .optional field.
*/
function markNode(node, optionalChains, result) {
if (optionalChains) {
if ("optional" in node && node.optional) {
if (!optionalChains.has(result)) optionalChains.set(result, true);
} else optionalChains.set(result, false);
}
}
/**
* Assuming () means the passed node.
* (foo) -> 'foo'
* foo(.)bar -> 'foo.bar'
* foo.bar(.)baz -> 'foo.bar.baz'
* Otherwise throw.
*/
function analyzePropertyChain(node, optionalChains) {
if (node.type === "Identifier" || node.type === "JSXIdentifier") {
const result = node.name;
if (optionalChains) optionalChains.set(result, false);
return result;
} else if (node.type === "MemberExpression" && !node.computed) {
const result = `${analyzePropertyChain(node.object, optionalChains)}.${analyzePropertyChain(node.property, null)}`;
markNode(node, optionalChains, result);
return result;
} else if (node.type === "OptionalMemberExpression" && !node.computed) {
const result = `${analyzePropertyChain(node.object, optionalChains)}.${analyzePropertyChain(node.property, null)}`;
markNode(node, optionalChains, result);
return result;
} else if (node.type === "ChainExpression" && (!("computed" in node) || !node.computed)) {
const expression = node.expression;
if (expression.type === "CallExpression") throw new Error(`Unsupported node type: ${expression.type}`);
const result = `${analyzePropertyChain(expression.object, optionalChains)}.${analyzePropertyChain(expression.property, null)}`;
markNode(expression, optionalChains, result);
return result;
} else throw new Error(`Unsupported node type: ${node.type}`);
}
function getNodeWithoutReactNamespace$1(node) {
if (node.type === "MemberExpression" && node.object.type === "Identifier" && node.object.name === "React" && node.property.type === "Identifier" && !node.computed) return node.property;
return node;
}
function getReactiveHookCallbackIndex(calleeNode, options) {
const node = getNodeWithoutReactNamespace$1(calleeNode);
if (node.type !== "Identifier") return -1;
switch (node.name) {
case "useEffect":
case "useLayoutEffect":
case "useCallback":
case "useMemo": return 0;
case "useImperativeHandle": return 1;
default: if (node === calleeNode && options && options.additionalHooks) {
let name;
try {
name = analyzePropertyChain(node, null);
} catch (error) {
if (error instanceof Error && /Unsupported node type/.test(error.message)) return 0;
else throw error;
}
return options.additionalHooks.test(name) ? 0 : -1;
} else return -1;
}
}
/**
* ESLint won't assign node.parent to references from context.getScope()
*
* So instead we search for the node from an ancestor assigning node.parent
* as we go. This mutates the AST.
*
* This traversal is:
* - optimized by only searching nodes with a range surrounding our target node
* - agnostic to AST node types, it looks for `{ type: string, ... }`
*/
function fastFindReferenceWithParent(start, target) {
const queue = [start];
let item;
while (queue.length) {
item = queue.shift();
if (isSameIdentifier(item, target)) return item;
if (!isAncestorNodeOf(item, target)) continue;
for (const [key, value] of Object.entries(item)) {
if (key === "parent") continue;
if (isNodeLike(value)) {
value.parent = item;
queue.push(value);
} else if (Array.isArray(value)) value.forEach((val) => {
if (isNodeLike(val)) {
val.parent = item;
queue.push(val);
}
});
}
}
return null;
}
function joinEnglish(arr) {
let s = "";
for (let i = 0; i < arr.length; i++) {
s += arr[i];
if (i === 0 && arr.length === 2) s += " and ";
else if (i === arr.length - 2 && arr.length > 2) s += ", and ";
else if (i < arr.length - 1) s += ", ";
}
return s;
}
function isNodeLike(val) {
return typeof val === "object" && val !== null && !Array.isArray(val) && "type" in val && typeof val.type === "string";
}
function isSameIdentifier(a, b) {
return (a.type === "Identifier" || a.type === "JSXIdentifier") && a.type === b.type && a.name === b.name && !!a.range && !!b.range && a.range[0] === b.range[0] && a.range[1] === b.range[1];
}
function isAncestorNodeOf(a, b) {
return !!a.range && !!b.range && a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
}
function isUseEffectEventIdentifier$1(node) {
return node.type === "Identifier" && node.name === "useEffectEvent";
}
function getUnknownDependenciesMessage(reactiveHookName) {
return `React Hook ${reactiveHookName} received a function whose dependencies are unknown. Pass an inline function instead.`;
}
//#endregion
//#region src/rules/globals/lib.ts
/**
* Array methods that mutate the array in place.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array
*/
const MUTATING_ARRAY_METHODS = /* @__PURE__ */ new Set([
"copyWithin",
"fill",
"pop",
"push",
"reverse",
"shift",
"sort",
"splice",
"unshift"
]);
/**
* Return whether an identifier is an unresolved global or is declared in the
*