/** * 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 */ import type { Rule, Scope } from 'eslint'; import type { CallExpression, Expression, Super, Node, Identifier, BaseFunction, } from 'estree'; import { __EXPERIMENTAL__ } from './index'; /** * Catch all identifiers that begin with "use" followed by an uppercase Latin * character to exclude identifiers like "user". */ function isHookName(s: string) { return s === 'use' || /^use[\dA-Z]/.test(s); } /** * We consider hooks to be a hook name identifier or a member expression * containing a hook name. */ function isHook(node: Node) { if (node.type === 'Identifier') { return isHookName(node.name); } else if ( node.type === 'MemberExpression' && !node.computed && isHook(node.property) ) { const obj = node.object; const isPascalCaseNameSpace = /^[A-Z].*/; return obj.type === 'Identifier' && isPascalCaseNameSpace.test(obj.name); } 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: Node) { return node.type === 'Identifier' && /^[A-Z]/.test(node.name); } function isReactFunction(node: Expression | Super, functionName: string) { return ( (node as Identifier).name === functionName || (node.type === 'MemberExpression' && (node.object as Identifier).name === 'React' && (node.property as Identifier).name === functionName) ); } /** * Checks if the node is a callback argument of forwardRef. This render function * should follow the rules of hooks. */ function isForwardRefCallback(node: Rule.Node) { return !!( (node.parent as CallExpression)?.callee && isReactFunction((node.parent as CallExpression).callee, '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: Rule.Node) { return !!( (node.parent as CallExpression)?.callee && isReactFunction((node.parent as CallExpression).callee, 'memo') ); } function isInsideComponentOrHook(node: Rule.Node) { while (node) { const functionName = getFunctionName(node); if (functionName && (isComponentName(functionName) || isHook(functionName))) { return true; } if (isForwardRefCallback(node) || isMemoCallback(node)) { return true; } node = node.parent; } return false; } function isUseEffectEventIdentifier(node: Node) { if (__EXPERIMENTAL__) { return node.type === 'Identifier' && node.name === 'useEffectEvent'; } return false; } function isUseIdentifier(node: Node) { return isReactFunction(node as Expression, 'use'); } const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'enforces the Rules of Hooks', recommended: true, url: 'https://reactjs.org/docs/hooks-rules.html', }, }, create(context) { let lastEffect: CallExpression | null = null; const codePathReactHooksMapStack: Map< Rule.CodePathSegment, (Expression | Super)[] >[] = []; const codePathSegmentStack: Rule.CodePathSegment[] = []; 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: Scope.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 && isUseEffectEventIdentifier(parent.init.callee) ) { for (const ref of reference.resolved!.references) { if (ref !== reference) { useEffectEventFunctions.add(ref.identifier); } } } } } /** * SourceCode#getText that also works down to ESLint 3.0.0 */ const getSource = typeof context.getSource === 'function' ? (node: Node) => context.getSource(node) : (node: Node) => context.sourceCode.getText(node); /** * SourceCode#getScope that also works down to ESLint 3.0.0 */ const getScope = typeof context.getScope === 'function' ? () => context.getScope() : (node: Node) => context.sourceCode.getScope(node); return { // Maintain code segment path stack as we traverse. onCodePathSegmentStart: segment => codePathSegmentStack.push(segment), onCodePathSegmentEnd: () => codePathSegmentStack.pop(), // Maintain code path stack as we traverse. onCodePathStart: () => codePathReactHooksMapStack.push(new Map()), // 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: Rule.CodePathSegment, pathHistory?: Set, ) { const { cache } = countPathsFromStart; let paths = cache.get(segment.id); 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 (pathList.has(segment.id)) { const pathArray = [...pathList]; const cyclicSegments = pathArray.slice(pathArray.indexOf(segment.id) + 1); for (const cyclicSegment of cyclicSegments) { cyclic.add(cyclicSegment); } return 0n; } // add the current segment to pathList pathList.add(segment.id); // We have a cached `paths`. Return it. if (paths !== undefined) { return paths; } if (codePath.thrownSegments.includes(segment)) { paths = 0n; } else if (segment.prevSegments.length === 0) { paths = 1n; } else { paths = 0n; for (const prevSegment of segment.prevSegments) { paths += countPathsFromStart(prevSegment, 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 === BigInt('0')) { cache.delete(segment.id); } else { cache.set(segment.id, 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: Rule.CodePathSegment, pathHistory?: Set, ): bigint { const { cache } = countPathsToEnd; let paths = cache.get(segment.id); 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 (pathList.has(segment.id)) { const pathArray = Array.from(pathList); const cyclicSegments = pathArray.slice(pathArray.indexOf(segment.id) + 1); for (const cyclicSegment of cyclicSegments) { cyclic.add(cyclicSegment); } return 0n; } // add the current segment to pathList pathList.add(segment.id); // We have a cached `paths`. Return it. if (paths !== undefined) { return paths; } if (codePath.thrownSegments.includes(segment)) { paths = 0n; } else if (segment.nextSegments.length === 0) { paths = 1n; } else { paths = 0n; for (const nextSegment of segment.nextSegments) { paths += countPathsToEnd(nextSegment, pathList); } } cache.set(segment.id, 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: Rule.CodePathSegment): number { const { cache } = shortestPathLengthToStart; let length = cache.get(segment.id); // 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. cache.set(segment.id, null); if (segment.prevSegments.length === 0) { length = 1; } else { length = Infinity; for (const prevSegment of segment.prevSegments) { const prevLength = shortestPathLengthToStart(prevSegment); if (prevLength < length) { length = prevLength; } } length += 1; } cache.set(segment.id, 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 = countPathsToEnd(codePath.initialSegment); // 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 = getFunctionName(codePathNode); // 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 = isInsideComponentOrHook(codePathNode); const isDirectlyInsideComponentOrHook = codePathFunctionName ? isComponentName(codePathFunctionName) || isHook(codePathFunctionName) : isForwardRefCallback(codePathNode) || isMemoCallback(codePathNode); // 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 = shortestPathLengthToStart(finalSegment); 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 <= shortestPathLengthToStart(segment) : shortestFinalPathLength < shortestPathLengthToStart(segment); // 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 = countPathsFromStart(segment) * countPathsToEnd(segment); // Is this hook a part of a cyclic segment? const cycled = cyclic.has(segment.id); 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 && !isUseIdentifier(hook)) { context.report({ node: hook, message: `React Hook "${getSource(hook)}" 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.', }); } // 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 as BaseFunction).async; if (isAsyncFunction) { context.report({ node: hook, message: `React Hook "${getSource(hook)}" cannot be ` + 'called in an async function.', }); } // 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 && !isUseIdentifier(hook) // `use(...)` can be called conditionally. ) { const message = `React Hook "${getSource(hook)}" 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?' : ''); context.report({ node: hook, message }); } } 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 "${getSource(hook)}" cannot be called ` + 'in a class component. React Hooks must be called in a ' + 'React function component or a custom React Hook function.'; context.report({ node: hook, message }); } else if (codePathFunctionName) { // Custom message if we found an invalid function name. const message = `React Hook "${getSource(hook)}" is called in ` + `function "${getSource(codePathFunctionName)}" ` + '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".'; context.report({ node: hook, message }); } else if (codePathNode.type === 'Program') { // These are dangerous if you have inline requires enabled. const message = `React Hook "${getSource(hook)}" cannot be called ` + 'at the top level. React Hooks must be called in a ' + 'React function component or a custom React Hook function.'; context.report({ node: hook, message }); } 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 && !isUseIdentifier(hook)) { const message = `React Hook "${getSource(hook)}" cannot be called ` + 'inside a callback. React Hooks must be called in a ' + 'React function component or a custom React Hook function.'; context.report({ node: hook, message }); } } } } }, // 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 (isHook(node.callee)) { // 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.at(-1)!; const codePathSegment = codePathSegmentStack.at(-1)!; let reactHooks = reactHooksMap.get(codePathSegment); if (!reactHooks) { reactHooks = []; reactHooksMap.set(codePathSegment, reactHooks); } reactHooks.push(node.callee); } // 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' || isUseEffectEventIdentifier(node.callee)) && 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 && useEffectEventFunctions.has(node) && node.parent.type !== 'CallExpression' ) { context.report({ node, message: `\`${getSource( node, )}\` 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.', }); } }, 'CallExpression:exit'(node) { if (node === lastEffect) { lastEffect = null; } }, FunctionDeclaration(node) { // function MyComponent() { const onClick = useEffectEvent(...) } if (isInsideComponentOrHook(node)) { recordAllUseEffectEventFunctions(getScope(node)); } }, ArrowFunctionExpression(node) { // const MyComponent = () => { const onClick = useEffectEvent(...) } if (isInsideComponentOrHook(node)) { recordAllUseEffectEventFunctions(getScope(node)); } }, }; }, }; /** * 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: Node) { const parent = node.parent!; 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 (parent.type === 'VariableDeclarator' && parent.init === node) { // const useHook = () => {}; return parent.id; } else if ( parent.type === 'AssignmentExpression' && parent.right === node && parent.operator === '=' ) { // useHook = () => {}; return parent.left; } else if (parent.type === 'Property' && parent.value === node && !parent.computed) { // {useHook: () => {}} // {useHook() {}} return 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 ( parent.type === 'AssignmentPattern' && parent.right === node && !parent.computed ) { // const {useHook = () => {}} = {}; // ({useHook = () => {}} = {}); // // Kinda clowny, but we'd said we'd follow spec convention for // `IsAnonymousFunctionDefinition()` usage. return parent.left; } else { return undefined; } } else { return undefined; } } export default rule;