diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 3314439cd35d5..662e9cc39b963 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -141,6 +141,46 @@ const tests = { return useHook1(useHook2()); } `, + ` + // Valid because hooks can be used in anonymous arrow-function arguments + // to forwardRef. + const FancyButton = React.forwardRef((props, ref) => { + useHook(); + return ; + }); + `, + errors: [conditionalError('useCustomHook')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + const FancyButton = forwardRef(function(props, ref) { + if (props.fancy) { + useCustomHook(); + } + return ; + }); + `, + errors: [conditionalError('useCustomHook')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + const MemoizedButton = memo(function(props) { + if (props.fancy) { + useCustomHook(); + } + return ; + }); + `, + errors: [conditionalError('useCustomHook')], + }, + { + code: ` + // This is invalid because "use"-prefixed functions used in named + // functions are assumed to be hooks. + React.unknownFunction(function notAComponent(foo, bar) { + useProbablyAHook(bar) + }); + `, + errors: [functionError('useProbablyAHook', 'notAComponent')], + }, { code: ` // Invalid because it's dangerous. diff --git a/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js index 73636e60daf16..dbcc9c3de59bb 100644 --- a/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js +++ b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js @@ -54,6 +54,41 @@ function isComponentName(node) { } } +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 && + isReactFunction(node.parent.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) { + return !!( + node.parent && + node.parent.callee && + isReactFunction(node.parent.callee, 'memo') + ); +} + function isInsideComponentOrHook(node) { while (node) { const functionName = getFunctionName(node); @@ -62,6 +97,9 @@ function isInsideComponentOrHook(node) { return true; } } + if (isForwardRefCallback(node) || isMemoCallback(node)) { + return true; + } node = node.parent; } return false; @@ -290,7 +328,8 @@ export default { // `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. + // 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 @@ -301,7 +340,7 @@ export default { const isDirectlyInsideComponentOrHook = codePathFunctionName ? isComponentName(codePathFunctionName) || isHook(codePathFunctionName) - : false; + : 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