} break; // Unhandled default: // useEffect(generateEffectBody(), []); reportProblem({ node: reactiveHook, message: "React Hook " + reactiveHookName + " received a function whose dependencies " + "are unknown. Pass an inline function instead." }); return; // Handled } // Something unusual. Fall back to suggesting to add the body itself as a dep. 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: function (fixer) { return fixer.replaceText(declaredDependenciesNode, "[" + callback.name + "]"); } }] }); } return { CallExpression: visitCallExpression }; } }; // The meat of the logic. function collectRecommendations(_ref6) { var dependencies = _ref6.dependencies, declaredDependencies = _ref6.declaredDependencies, stableDependencies = _ref6.stableDependencies, externalDependencies = _ref6.externalDependencies, isEffect = _ref6.isEffect; // Our primary data structure. // It is a logical representation of property chains: // `props` -> `props.foo` -> `props.foo.bar` -> `props.foo.bar.baz` // -> `props.lol` // -> `props.huh` -> `props.huh.okay` // -> `props.wow` // We'll use it to mark nodes that are *used* by the programmer, // and the nodes that were *declared* as deps. Then we will // traverse it to learn which deps are missing or unnecessary. var depTree = createDepTree(); function createDepTree() { return { isUsed: false, // True if used in code isSatisfiedRecursively: false, // True if specified in deps isSubtreeUsed: false, // True if something deeper is used by code children: new Map() // Nodes for properties }; } // Mark all required nodes first. // Imagine exclamation marks next to each used deep property. dependencies.forEach(function (_, key) { var node = getOrCreateNodeByPath(depTree, key); node.isUsed = true; markAllParentsByPath(depTree, key, function (parent) { parent.isSubtreeUsed = true; }); }); // Mark all satisfied nodes. // Imagine checkmarks next to each declared dependency. declaredDependencies.forEach(function (_ref7) { var key = _ref7.key; var node = getOrCreateNodeByPath(depTree, key); node.isSatisfiedRecursively = true; }); stableDependencies.forEach(function (key) { var node = getOrCreateNodeByPath(depTree, key); node.isSatisfiedRecursively = true; }); // Tree manipulation helpers. function getOrCreateNodeByPath(rootNode, path) { var keys = path.split('.'); var node = rootNode; var _iterator3 = _createForOfIteratorHelper(keys), _step3; try { for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) { var key = _step3.value; var child = node.children.get(key); if (!child) { child = createDepTree(); node.children.set(key, child); } node = child; } } catch (err) { _iterator3.e(err); } finally { _iterator3.f(); } return node; } function markAllParentsByPath(rootNode, path, fn) { var keys = path.split('.'); var node = rootNode; var _iterator4 = _createForOfIteratorHelper(keys), _step4; try { for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) { var key = _step4.value; var child = node.children.get(key); if (!child) { return; } fn(child); node = child; } } catch (err) { _iterator4.e(err); } finally { _iterator4.f(); } } // Now we can learn which dependencies are missing or necessary. var missingDependencies = new Set(); var satisfyingDependencies = new Set(); scanTreeRecursively(depTree, missingDependencies, satisfyingDependencies, function (key) { return key; }); function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) { node.children.forEach(function (child, key) { var path = keyToPath(key); if (child.isSatisfiedRecursively) { if (child.isSubtreeUsed) { // Remember this dep actually satisfied something. satisfyingPaths.add(path); } // It doesn't matter if there's something deeper. // It would be transitively satisfied since we assume immutability. // `props.foo` is enough if you read `props.foo.id`. return; } if (child.isUsed) { // Remember that no declared deps satisfied this node. missingPaths.add(path); // If we got here, nothing in its subtree was satisfied. // No need to search further. return; } scanTreeRecursively(child, missingPaths, satisfyingPaths, function (childKey) { return path + '.' + childKey; }); }); } // Collect suggestions in the order they were originally specified. var suggestedDependencies = []; var unnecessaryDependencies = new Set(); var duplicateDependencies = new Set(); declaredDependencies.forEach(function (_ref8) { var key = _ref8.key; // Does this declared dep satisfy a real need? if (satisfyingDependencies.has(key)) { if (suggestedDependencies.indexOf(key) === -1) { // Good one. suggestedDependencies.push(key); } else { // Duplicate. duplicateDependencies.add(key); } } else { if (isEffect && !key.endsWith('.current') && !externalDependencies.has(key)) { // Effects are allowed extra "unnecessary" deps. // Such as resetting scroll when ID changes. // Consider them legit. // The exception is ref.current which is always wrong. if (suggestedDependencies.indexOf(key) === -1) { suggestedDependencies.push(key); } } else { // It's definitely not needed. unnecessaryDependencies.add(key); } } }); // Then add the missing ones at the end. missingDependencies.forEach(function (key) { suggestedDependencies.push(key); }); return { suggestedDependencies: suggestedDependencies, unnecessaryDependencies: unnecessaryDependencies, duplicateDependencies: duplicateDependencies, missingDependencies: missingDependencies }; } // If the node will result in constructing a referentially unique value, return // its human readable type name, else return null. 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': return getConstructionExpressionType(node.expression); case 'TSAsExpression': return getConstructionExpressionType(node.expression); } return null; } // Finds variables declared as dependencies // that would invalidate on every render. function scanForConstructions(_ref9) { var declaredDependencies = _ref9.declaredDependencies, declaredDependenciesNode = _ref9.declaredDependenciesNode, componentScope = _ref9.componentScope, scope = _ref9.scope; var constructions = declaredDependencies.map(function (_ref10) { var key = _ref10.key; var ref = componentScope.variables.find(function (v) { return v.name === key; }); if (ref == null) { return null; } var node = ref.defs[0]; if (node == null) { return null; } // const handleChange = function () {} // const handleChange = () => {} // const foo = {} // const foo = [] // etc. if (node.type === 'Variable' && node.node.type === 'VariableDeclarator' && node.node.id.type === 'Identifier' && // Ensure this is not destructed assignment node.node.init != null) { var constantExpressionType = getConstructionExpressionType(node.node.init); if (constantExpressionType != null) { return [ref, constantExpressionType]; } } // function handleChange() {} if (node.type === 'FunctionName' && node.node.type === 'FunctionDeclaration') { return [ref, 'function']; } // class Foo {} if (node.type === 'ClassName' && node.node.type === 'ClassDeclaration') { return [ref, 'class']; } return null; }).filter(Boolean); function isUsedOutsideOfHook(ref) { var foundWriteExpr = false; for (var i = 0; i < ref.references.length; i++) { var reference = ref.references[i]; if (reference.writeExpr) { if (foundWriteExpr) { // Two writes to the same function. return true; } else { // Ignore first write as it's not usage. foundWriteExpr = true; continue; } } var currentScope = reference.from; while (currentScope !== scope && currentScope != null) { currentScope = currentScope.upper; } if (currentScope !== scope) { // This reference is outside the Hook callback. // It can only be legit if it's the deps array. if (!isAncestorNodeOf(declaredDependenciesNode, reference.identifier)) { return true; } } } return false; } return constructions.map(function (_ref11) { var ref = _ref11[0], depType = _ref11[1]; return { construction: ref.defs[0], depType: 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.type === 'MemberExpression' || node.parent.type === 'OptionalMemberExpression') && node.parent.object === node && 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 ( // Note: we don't check OptionalMemberExpression because it can't be LHS. 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 (node.optional) { // We only want to consider it optional if *all* usages were optional. if (!optionalChains.has(result)) { // Mark as (maybe) optional. If there's a required usage, this will be overridden. optionalChains.set(result, true); } } else { // Mark as required. 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') { var result = node.name; if (optionalChains) { // Mark as required. optionalChains.set(result, false); } return result; } else if (node.type === 'MemberExpression' && !node.computed) { var object = analyzePropertyChain(node.object, optionalChains); var property = analyzePropertyChain(node.property, null); var _result = object + "." + property; markNode(node, optionalChains, _result); return _result; } else if (node.type === 'OptionalMemberExpression' && !node.computed) { var _object = analyzePropertyChain(node.object, optionalChains); var _property = analyzePropertyChain(node.property, null); var _result2 = _object + "." + _property; markNode(node, optionalChains, _result2); return _result2; } else if (node.type === 'ChainExpression' && !node.computed) { var expression = node.expression; if (expression.type === 'CallExpression') { throw new Error("Unsupported node type: " + expression.type); } var _object2 = analyzePropertyChain(expression.object, optionalChains); var _property2 = analyzePropertyChain(expression.property, null); var _result3 = _object2 + "." + _property2; markNode(expression, optionalChains, _result3); return _result3; } else { throw new Error("Unsupported node type: " + node.type); } } function getNodeWithoutReactNamespace(node, options) { if (node.type === 'MemberExpression' && node.object.type === 'Identifier' && node.object.name === 'React' && node.property.type === 'Identifier' && !node.computed) { return node.property; } return node; } // What's the index of callback that needs to be analyzed for a given Hook? // -1 if it's not a Hook we care about (e.g. useState). // 0 for useEffect/useMemo/useCallback(fn). // 1 for useImperativeHandle(ref, fn). // For additionally configured Hooks, assume that they're like useEffect (0). function getReactiveHookCallbackIndex(calleeNode, options) { var node = getNodeWithoutReactNamespace(calleeNode); if (node.type !== 'Identifier') { return -1; } switch (node.name) { case 'useEffect': case 'useLayoutEffect': case 'useCallback': case 'useMemo': // useEffect(fn) return 0; case 'useImperativeHandle': // useImperativeHandle(ref, fn) return 1; default: if (node === calleeNode && options && options.additionalHooks) { // Allow the user to provide a regular expression which enables the lint to // target custom reactive hooks. var name; try { name = analyzePropertyChain(node, null); } catch (error) { if (/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) { var queue = [start]; var item = null; while (queue.length) { item = queue.shift(); if (isSameIdentifier(item, target)) { return item; } if (!isAncestorNodeOf(item, target)) { continue; } for (var _i4 = 0, _Object$entries = Object.entries(item); _i4 < _Object$entries.length; _i4++) { var _Object$entries$_i = _Object$entries[_i4], key = _Object$entries$_i[0], value = _Object$entries$_i[1]; if (key === 'parent') { continue; } if (isNodeLike(value)) { value.parent = item; queue.push(value); } else if (Array.isArray(value)) { value.forEach(function (val) { if (isNodeLike(val)) { val.parent = item; queue.push(val); } }); } } } return null; } function joinEnglish(arr) { var s = ''; for (var 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) && 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[0] === b.range[0] && a.range[1] === b.range[1]; } function isAncestorNodeOf(a, b) { return a.range[0] <= b.range[0] && a.range[1] >= b.range[1]; } var configs = { recommended: { plugins: ['react-hooks'], rules: { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn' } } }; var rules = { 'rules-of-hooks': RulesOfHooks, 'exhaustive-deps': ExhaustiveDeps }; exports.configs = configs; exports.rules = rules; })(); }