UNPKG

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
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 *