/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /* global BigInt */ /* eslint-disable no-for-of-loops/no-for-of-loops */ 'use strict'; /** * Catch all identifiers that begin with "use" followed by an uppercase Latin * character to exclude identifiers like "user". */ function isHookName(s) { return s === 'use' || s |> /^use[A-Z0-9]/.test(%); } /** * We consider hooks to be a hook name identifier or a member expression * containing a hook name. */ function isHook(node) { if (node.type === 'Identifier') { return node.name |> isHookName(%); } else if (node.type === 'MemberExpression' && !node.computed && (node.property |> isHook(%))) { const obj = node.object; const isPascalCaseNameSpace = /^[A-Z].*/; return obj.type === 'Identifier' && (obj.name |> isPascalCaseNameSpace.test(%)); } else { return false; } } /** * Checks if the node is a React component name. React component names must * always start with an uppercase letter. */ function isComponentName(node) { return node.type === 'Identifier' && (node.name |> /^[A-Z]/.test(%)); } function isReactFunction(node, functionName) { return node.name === functionName || node.type === 'MemberExpression' && node.object.name === 'React' && node.property.name === functionName; } /** * Checks if the node is a callback argument of forwardRef. This render function * should follow the rules of hooks. */ function isForwardRefCallback(node) { return !!(node.parent && node.parent.callee && (node.parent.callee |> isReactFunction(%, 'forwardRef'))); } /** * Checks if the node is a callback argument of React.memo. This anonymous * functional component should follow the rules of hooks. */ function isMemoCallback(node) { return !!(node.parent && node.parent.callee && (node.parent.callee |> isReactFunction(%, 'memo'))); } function isInsideComponentOrHook(node) { while (node) { const functionName = node |> getFunctionName(%); if (functionName) { if (functionName |> isComponentName(%) || functionName |> isHook(%)) { return true; } } if (node |> isForwardRefCallback(%) || node |> isMemoCallback(%)) { return true; } node = node.parent; } return false; } function isUseEffectEventIdentifier(node) { if (__EXPERIMENTAL__) { return node.type === 'Identifier' && node.name === 'useEffectEvent'; } return false; } function isUseIdentifier(node) { return node |> isReactFunction(%, 'use'); } export default { meta: { type: 'problem', docs: { description: 'enforces the Rules of Hooks', recommended: true, url: 'https://reactjs.org/docs/hooks-rules.html' } }, create(context) { let lastEffect = null; const codePathReactHooksMapStack = []; const codePathSegmentStack = []; const useEffectEventFunctions = new WeakSet(); // For a given scope, iterate through the references and add all useEffectEvent definitions. We can // do this in non-Program nodes because we can rely on the assumption that useEffectEvent functions // can only be declared within a component or hook at its top level. function recordAllUseEffectEventFunctions(scope) { for (const reference of scope.references) { const parent = reference.identifier.parent; if (parent.type === 'VariableDeclarator' && parent.init && parent.init.type === 'CallExpression' && parent.init.callee && (parent.init.callee |> isUseEffectEventIdentifier(%))) { for (const ref of reference.resolved.references) { if (ref !== reference) { ref.identifier |> useEffectEventFunctions.add(%); } } } } } /** * SourceCode#getText that also works down to ESLint 3.0.0 */ const getSource = typeof context.getSource === 'function' ? node => { return node |> context.getSource(%); } : node => { return node |> context.sourceCode.getText(%); }; /** * SourceCode#getScope that also works down to ESLint 3.0.0 */ const getScope = typeof context.getScope === 'function' ? () => { return context.getScope(); } : node => { return node |> context.sourceCode.getScope(%); }; return { // Maintain code segment path stack as we traverse. onCodePathSegmentStart: segment => segment |> codePathSegmentStack.push(%), onCodePathSegmentEnd: () => codePathSegmentStack.pop(), // Maintain code path stack as we traverse. onCodePathStart: () => new Map() |> codePathReactHooksMapStack.push(%), // Process our code path. // // Everything is ok if all React Hooks are both reachable from the initial // segment and reachable from every final segment. onCodePathEnd(codePath, codePathNode) { const reactHooksMap = codePathReactHooksMapStack.pop(); if (reactHooksMap.size === 0) { return; } // All of the segments which are cyclic are recorded in this set. const cyclic = new Set(); /** * Count the number of code paths from the start of the function to this * segment. For example: * * ```js * function MyComponent() { * if (condition) { * // Segment 1 * } else { * // Segment 2 * } * // Segment 3 * } * ``` * * Segments 1 and 2 have one path to the beginning of `MyComponent` and * segment 3 has two paths to the beginning of `MyComponent` since we * could have either taken the path of segment 1 or segment 2. * * Populates `cyclic` with cyclic segments. */ function countPathsFromStart(segment, pathHistory) { const { cache } = countPathsFromStart; let paths = segment.id |> cache.get(%); const pathList = new Set(pathHistory); // If `pathList` includes the current segment then we've found a cycle! // We need to fill `cyclic` with all segments inside cycle if (segment.id |> pathList.has(%)) { const pathArray = [...pathList]; const cyclicSegments = (segment.id |> pathArray.indexOf(%)) + 1 |> pathArray.slice(%); for (const cyclicSegment of cyclicSegments) { cyclicSegment |> cyclic.add(%); } return '0' |> BigInt(%); } // add the current segment to pathList // We have a cached `paths`. Return it. segment.id |> pathList.add(%); if (paths !== undefined) { return paths; } if (segment |> codePath.thrownSegments.includes(%)) { paths = '0' |> BigInt(%); } else if (segment.prevSegments.length === 0) { paths = '1' |> BigInt(%); } else { paths = '0' |> BigInt(%); for (const prevSegment of segment.prevSegments) { paths += prevSegment |> countPathsFromStart(%, pathList); } } // If our segment is reachable then there should be at least one path // to it from the start of our code path. if (segment.reachable && paths === ('0' |> BigInt(%))) { segment.id |> cache.delete(%); } else { segment.id |> cache.set(%, paths); } return paths; } /** * Count the number of code paths from this segment to the end of the * function. For example: * * ```js * function MyComponent() { * // Segment 1 * if (condition) { * // Segment 2 * } else { * // Segment 3 * } * } * ``` * * Segments 2 and 3 have one path to the end of `MyComponent` and * segment 1 has two paths to the end of `MyComponent` since we could * either take the path of segment 1 or segment 2. * * Populates `cyclic` with cyclic segments. */ function countPathsToEnd(segment, pathHistory) { const { cache } = countPathsToEnd; let paths = segment.id |> cache.get(%); const pathList = new Set(pathHistory); // If `pathList` includes the current segment then we've found a cycle! // We need to fill `cyclic` with all segments inside cycle if (segment.id |> pathList.has(%)) { const pathArray = pathList |> Array.from(%); const cyclicSegments = (segment.id |> pathArray.indexOf(%)) + 1 |> pathArray.slice(%); for (const cyclicSegment of cyclicSegments) { cyclicSegment |> cyclic.add(%); } return '0' |> BigInt(%); } // add the current segment to pathList // We have a cached `paths`. Return it. segment.id |> pathList.add(%); if (paths !== undefined) { return paths; } if (segment |> codePath.thrownSegments.includes(%)) { paths = '0' |> BigInt(%); } else if (segment.nextSegments.length === 0) { paths = '1' |> BigInt(%); } else { paths = '0' |> BigInt(%); for (const nextSegment of segment.nextSegments) { paths += nextSegment |> countPathsToEnd(%, pathList); } } segment.id |> cache.set(%, paths); return paths; } /** * Gets the shortest path length to the start of a code path. * For example: * * ```js * function MyComponent() { * if (condition) { * // Segment 1 * } * // Segment 2 * } * ``` * * There is only one path from segment 1 to the code path start. Its * length is one so that is the shortest path. * * There are two paths from segment 2 to the code path start. One * through segment 1 with a length of two and another directly to the * start with a length of one. The shortest path has a length of one * so we would return that. */ function shortestPathLengthToStart(segment) { const { cache } = shortestPathLengthToStart; let length = segment.id |> cache.get(%); // If `length` is null then we found a cycle! Return infinity since // the shortest path is definitely not the one where we looped. if (length === null) { return Infinity; } // We have a cached `length`. Return it. if (length !== undefined) { return length; } // Compute `length` and cache it. Guarding against cycles. segment.id |> cache.set(%, null); if (segment.prevSegments.length === 0) { length = 1; } else { length = Infinity; for (const prevSegment of segment.prevSegments) { const prevLength = prevSegment |> shortestPathLengthToStart(%); if (prevLength < length) { length = prevLength; } } length += 1; } segment.id |> cache.set(%, length); return length; } countPathsFromStart.cache = new Map(); countPathsToEnd.cache = new Map(); shortestPathLengthToStart.cache = new Map(); // Count all code paths to the end of our component/hook. Also primes // the `countPathsToEnd` cache. const allPathsFromStartToEnd = codePath.initialSegment |> countPathsToEnd(%); // Gets the function name for our code path. If the function name is // `undefined` then we know either that we have an anonymous function // expression or our code path is not in a function. In both cases we // will want to error since neither are React function components or // hook functions - unless it is an anonymous function argument to // forwardRef or memo. const codePathFunctionName = codePathNode |> getFunctionName(%); // This is a valid code path for React hooks if we are directly in a React // function component or we are in a hook function. const isSomewhereInsideComponentOrHook = codePathNode |> isInsideComponentOrHook(%); const isDirectlyInsideComponentOrHook = codePathFunctionName ? codePathFunctionName |> isComponentName(%) || codePathFunctionName |> isHook(%) : codePathNode |> isForwardRefCallback(%) || codePathNode |> isMemoCallback(%); // Compute the earliest finalizer level using information from the // cache. We expect all reachable final segments to have a cache entry // after calling `visitSegment()`. let shortestFinalPathLength = Infinity; for (const finalSegment of codePath.finalSegments) { if (!finalSegment.reachable) { continue; } const length = finalSegment |> shortestPathLengthToStart(%); if (length < shortestFinalPathLength) { shortestFinalPathLength = length; } } // Make sure all React Hooks pass our lint invariants. Log warnings // if not. for (const [segment, reactHooks] of reactHooksMap) { // NOTE: We could report here that the hook is not reachable, but // that would be redundant with more general "no unreachable" // lint rules. if (!segment.reachable) { continue; } // If there are any final segments with a shorter path to start then // we possibly have an early return. // // If our segment is a final segment itself then siblings could // possibly be early returns. const possiblyHasEarlyReturn = segment.nextSegments.length === 0 ? shortestFinalPathLength <= (segment |> shortestPathLengthToStart(%)) : shortestFinalPathLength < (segment |> shortestPathLengthToStart(%)); // Count all the paths from the start of our code path to the end of // our code path that go _through_ this segment. The critical piece // of this is _through_. If we just call `countPathsToEnd(segment)` // then we neglect that we may have gone through multiple paths to get // to this point! Consider: // // ```js // function MyComponent() { // if (a) { // // Segment 1 // } else { // // Segment 2 // } // // Segment 3 // if (b) { // // Segment 4 // } else { // // Segment 5 // } // } // ``` // // In this component we have four code paths: // // 1. `a = true; b = true` // 2. `a = true; b = false` // 3. `a = false; b = true` // 4. `a = false; b = false` // // From segment 3 there are two code paths to the end through segment // 4 and segment 5. However, we took two paths to get here through // segment 1 and segment 2. // // If we multiply the paths from start (two) by the paths to end (two) // for segment 3 we get four. Which is our desired count. const pathsFromStartToEnd = (segment |> countPathsFromStart(%)) * (segment |> countPathsToEnd(%)); // Is this hook a part of a cyclic segment? const cycled = segment.id |> cyclic.has(%); for (const hook of reactHooks) { // Report an error if a hook may be called more then once. // `use(...)` can be called in loops. if (cycled && !(hook |> isUseIdentifier(%))) { ({ node: hook, message: `React Hook "${hook |> getSource(%)}" may be executed ` + 'more than once. Possibly because it is called in a loop. ' + 'React Hooks must be called in the exact same order in ' + 'every component render.' }) |> context.report(%); } // If this is not a valid code path for React hooks then we need to // log a warning for every hook in this code path. // // Pick a special message depending on the scope this hook was // called in. if (isDirectlyInsideComponentOrHook) { // Report an error if the hook is called inside an async function. const isAsyncFunction = codePathNode.async; if (isAsyncFunction) { ({ node: hook, message: `React Hook "${hook |> getSource(%)}" cannot be ` + 'called in an async function.' }) |> context.report(%); } // Report an error if a hook does not reach all finalizing code // path segments. // // Special case when we think there might be an early return. if (!cycled && pathsFromStartToEnd !== allPathsFromStartToEnd && !(hook |> isUseIdentifier(%)) // `use(...)` can be called conditionally. ) { const message = `React Hook "${hook |> getSource(%)}" is called ` + 'conditionally. React Hooks must be called in the exact ' + 'same order in every component render.' + (possiblyHasEarlyReturn ? ' Did you accidentally call a React Hook after an' + ' early return?' : ''); ({ node: hook, message }) |> context.report(%); } } else if (codePathNode.parent && (codePathNode.parent.type === 'MethodDefinition' || codePathNode.parent.type === 'ClassProperty') && codePathNode.parent.value === codePathNode) { // Custom message for hooks inside a class const message = `React Hook "${hook |> getSource(%)}" cannot be called ` + 'in a class component. React Hooks must be called in a ' + 'React function component or a custom React Hook function.'; ({ node: hook, message }) |> context.report(%); } else if (codePathFunctionName) { // Custom message if we found an invalid function name. const message = `React Hook "${hook |> getSource(%)}" is called in ` + `function "${codePathFunctionName |> getSource(%)}" ` + 'that is neither a React function component nor a custom ' + 'React Hook function.' + ' React component names must start with an uppercase letter.' + ' React Hook names must start with the word "use".'; ({ node: hook, message }) |> context.report(%); } else if (codePathNode.type === 'Program') { // These are dangerous if you have inline requires enabled. const message = `React Hook "${hook |> getSource(%)}" cannot be called ` + 'at the top level. React Hooks must be called in a ' + 'React function component or a custom React Hook function.'; ({ node: hook, message }) |> context.report(%); } else { // Assume in all other cases the user called a hook in some // random function callback. This should usually be true for // anonymous function expressions. Hopefully this is clarifying // enough in the common case that the incorrect message in // uncommon cases doesn't matter. // `use(...)` can be called in callbacks. if (isSomewhereInsideComponentOrHook && !(hook |> isUseIdentifier(%))) { const message = `React Hook "${hook |> getSource(%)}" cannot be called ` + 'inside a callback. React Hooks must be called in a ' + 'React function component or a custom React Hook function.'; ({ node: hook, message }) |> context.report(%); } } } } }, // Missed opportunity...We could visit all `Identifier`s instead of all // `CallExpression`s and check that _every use_ of a hook name is valid. // But that gets complicated and enters type-system territory, so we're // only being strict about hook calls for now. CallExpression(node) { if (node.callee |> isHook(%)) { // Add the hook node to a map keyed by the code path segment. We will // do full code path analysis at the end of our code path. const reactHooksMap = codePathReactHooksMapStack |> last(%); const codePathSegment = codePathSegmentStack |> last(%); let reactHooks = codePathSegment |> reactHooksMap.get(%); if (!reactHooks) { reactHooks = []; codePathSegment |> reactHooksMap.set(%, reactHooks); } node.callee |> reactHooks.push(%); } // useEffectEvent: useEffectEvent functions can be passed by reference within useEffect as well as in // another useEffectEvent if (node.callee.type === 'Identifier' && (node.callee.name === 'useEffect' || node.callee |> isUseEffectEventIdentifier(%)) && node.arguments.length > 0) { // Denote that we have traversed into a useEffect call, and stash the CallExpr for // comparison later when we exit lastEffect = node; } }, Identifier(node) { // This identifier resolves to a useEffectEvent function, but isn't being referenced in an // effect or another event function. It isn't being called either. if (lastEffect == null && (node |> useEffectEventFunctions.has(%)) && node.parent.type !== 'CallExpression') { ({ node, message: `\`${node |> getSource(%)}\` is a function created with React Hook "useEffectEvent", and can only be called from ` + 'the same component. They cannot be assigned to variables or passed down.' }) |> context.report(%); } }, 'CallExpression:exit'(node) { if (node === lastEffect) { lastEffect = null; } }, FunctionDeclaration(node) { // function MyComponent() { const onClick = useEffectEvent(...) } if (node |> isInsideComponentOrHook(%)) { node |> getScope(%) |> recordAllUseEffectEventFunctions(%); } }, ArrowFunctionExpression(node) { // const MyComponent = () => { const onClick = useEffectEvent(...) } if (node |> isInsideComponentOrHook(%)) { node |> getScope(%) |> recordAllUseEffectEventFunctions(%); } } }; } }; /** * Gets the static name of a function AST node. For function declarations it is * easy. For anonymous function expressions it is much harder. If you search for * `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places * where JS gives anonymous function expressions names. We roughly detect the * same AST nodes with some exceptions to better fit our use case. */ function getFunctionName(node) { if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' && node.id) { // function useHook() {} // const whatever = function useHook() {}; // // Function declaration or function expression names win over any // assignment statements or other renames. return node.id; } else if (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') { if (node.parent.type === 'VariableDeclarator' && node.parent.init === node) { // const useHook = () => {}; return node.parent.id; } else if (node.parent.type === 'AssignmentExpression' && node.parent.right === node && node.parent.operator === '=') { // useHook = () => {}; return node.parent.left; } else if (node.parent.type === 'Property' && node.parent.value === node && !node.parent.computed) { // {useHook: () => {}} // {useHook() {}} return node.parent.key; // NOTE: We could also support `ClassProperty` and `MethodDefinition` // here to be pedantic. However, hooks in a class are an anti-pattern. So // we don't allow it to error early. // // class {useHook = () => {}} // class {useHook() {}} } else if (node.parent.type === 'AssignmentPattern' && node.parent.right === node && !node.parent.computed) { // const {useHook = () => {}} = {}; // ({useHook = () => {}} = {}); // // Kinda clowny, but we'd said we'd follow spec convention for // `IsAnonymousFunctionDefinition()` usage. return node.parent.left; } else { return undefined; } } else { return undefined; } } /** * Convenience function for peeking the last item in a stack. */ function last(array) { return array[array.length - 1]; }