diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 3d60a36824d..4ddfd6b12cc 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -259,6 +259,20 @@ const allTests = { } `, }, + { + code: normalizeIndent` + // Valid because this is not a React component class. + // Hooks can be called in regular class methods. + class Store { + use() { + return React.useState(4); + } + useHook() { + return useState(0); + } + } + `, + }, { code: normalizeIndent` // Valid -- this is a regression test. @@ -1537,6 +1551,26 @@ const allTests = { `, errors: [classError('React.useState')], }, + { + code: normalizeIndent` + class MyComponent extends React.Component { + componentDidMount() { + useState(0); + } + } + `, + errors: [classError('useState')], + }, + { + code: normalizeIndent` + class MyComponent extends Component { + useCustomHook() { + return useHook(); + } + } + `, + errors: [classError('useHook')], + }, { code: normalizeIndent` (class {useHook = () => { useState(); }}); @@ -1788,7 +1822,7 @@ const allTests = { })} />; } `, - errors: [{...useEffectEventError(null, false), line: 4}], + errors: [{ ...useEffectEventError(null, false), line: 4 }], }, { syntax: 'flow', @@ -1801,7 +1835,7 @@ const allTests = { })} />; } `, - errors: [{...useEffectEventError(null, false), line: 5}], + errors: [{ ...useEffectEventError(null, false), line: 5 }], }, { code: normalizeIndent` @@ -1834,8 +1868,8 @@ const allTests = { } `, errors: [ - {...useEffectEventError('onClick', false), line: 7}, - {...useEffectEventError('onClick', true), line: 15}, + { ...useEffectEventError('onClick', false), line: 7 }, + { ...useEffectEventError('onClick', true), line: 15 }, ], }, { @@ -1871,8 +1905,8 @@ const allTests = { } `, errors: [ - {...useEffectEventError('onClick', false), line: 8}, - {...useEffectEventError('onClick', true), line: 16}, + { ...useEffectEventError('onClick', false), line: 8 }, + { ...useEffectEventError('onClick', true), line: 16 }, ], }, { @@ -1897,7 +1931,7 @@ const allTests = { return } `, - errors: [{...useEffectEventError('onClick', false), line: 7}], + errors: [{ ...useEffectEventError('onClick', false), line: 7 }], }, { syntax: 'flow', @@ -1912,7 +1946,7 @@ const allTests = { return } `, - errors: [{...useEffectEventError('onClick', false), line: 8}], + errors: [{ ...useEffectEventError('onClick', false), line: 8 }], }, { code: normalizeIndent` @@ -1972,15 +2006,15 @@ const allTests = { // Explicitly test error messages here for various cases errors: [ `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - 'Effects and Effect Events in the same component.', + 'Effects and Effect Events in the same component.', `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - 'Effects and Effect Events in the same component.', + 'Effects and Effect Events in the same component.', `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - `Effects and Effect Events in the same component. ` + - `It cannot be assigned to a variable or passed down.`, + `Effects and Effect Events in the same component. ` + + `It cannot be assigned to a variable or passed down.`, `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - `Effects and Effect Events in the same component. ` + - `It cannot be assigned to a variable or passed down.`, + `Effects and Effect Events in the same component. ` + + `It cannot be assigned to a variable or passed down.`, ], }, { @@ -2009,15 +2043,15 @@ const allTests = { // Explicitly test error messages here for various cases errors: [ `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - 'Effects and Effect Events in the same component.', + 'Effects and Effect Events in the same component.', `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - 'Effects and Effect Events in the same component.', + 'Effects and Effect Events in the same component.', `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - `Effects and Effect Events in the same component. ` + - `It cannot be assigned to a variable or passed down.`, + `Effects and Effect Events in the same component. ` + + `It cannot be assigned to a variable or passed down.`, `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + - `Effects and Effect Events in the same component. ` + - `It cannot be assigned to a variable or passed down.`, + `Effects and Effect Events in the same component. ` + + `It cannot be assigned to a variable or passed down.`, ], }, ], diff --git a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts index ca82c99e2f5..19e4133e2fb 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts @@ -6,7 +6,7 @@ */ /* eslint-disable no-for-of-loops/no-for-of-loops */ -import type {Rule, Scope} from 'eslint'; +import type { Rule, Scope } from 'eslint'; import type { CallExpression, CatchClause, @@ -20,7 +20,7 @@ import type { // @ts-expect-error untyped module import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer'; -import {getAdditionalEffectHooksFromSettings} from '../shared/Utils'; +import { getAdditionalEffectHooksFromSettings } from '../shared/Utils'; /** * Catch all identifiers that begin with "use" followed by an uppercase Latin @@ -191,6 +191,34 @@ function isUseIdentifier(node: Node): boolean { return isReactFunction(node, 'use'); } +function isReactComponentClass(node: Node): boolean { + // Walk up the tree to find the class declaration + let current: Node | undefined = node; + while (current) { + if (current.type === 'ClassDeclaration' || current.type === 'ClassExpression') { + const classNode = current as any; + if (classNode.superClass) { + const superClass = classNode.superClass; + // Check for: extends Component, extends React.Component, extends PureComponent, extends React.PureComponent + if (superClass.type === 'Identifier') { + return superClass.name === 'Component' || superClass.name === 'PureComponent'; + } else if (superClass.type === 'MemberExpression') { + return ( + superClass.object.type === 'Identifier' && + superClass.object.name === 'React' && + superClass.property.type === 'Identifier' && + (superClass.property.name === 'Component' || superClass.property.name === 'PureComponent') + ); + } + } + return false; + } + current = current.parent; + } + return false; +} + + const rule = { meta: { type: 'problem', @@ -255,22 +283,22 @@ const rule = { const getSourceCode = typeof context.getSourceCode === 'function' ? () => { - return context.getSourceCode(); - } + return context.getSourceCode(); + } : () => { - return context.sourceCode; - }; + return context.sourceCode; + }; /** * SourceCode#getScope that also works down to ESLint 3.0.0 */ const getScope = typeof context.getScope === 'function' ? (): Scope.Scope => { - return context.getScope(); - } + return context.getScope(); + } : (node: Node): Scope.Scope => { - return getSourceCode().getScope(node); - }; + return getSourceCode().getScope(node); + }; function hasFlowSuppression(node: Node, suppression: string) { const sourceCode = getSourceCode(); @@ -339,7 +367,7 @@ const rule = { segment: Rule.CodePathSegment, pathHistory?: Set, ): bigint { - const {cache} = countPathsFromStart; + const { cache } = countPathsFromStart; let paths = cache.get(segment.id); const pathList = new Set(pathHistory); @@ -413,7 +441,7 @@ const rule = { segment: Rule.CodePathSegment, pathHistory?: Set, ): bigint { - const {cache} = countPathsToEnd; + const { cache } = countPathsToEnd; let paths = cache.get(segment.id); const pathList = new Set(pathHistory); @@ -479,7 +507,7 @@ const rule = { function shortestPathLengthToStart( segment: Rule.CodePathSegment, ): number { - const {cache} = shortestPathLengthToStart; + const { cache } = shortestPathLengthToStart; let length = cache.get(segment.id); // If `length` is null then we found a cycle! Return infinity since @@ -533,7 +561,7 @@ const rule = { isInsideComponentOrHook(codePathNode); const isDirectlyInsideComponentOrHook = codePathFunctionName ? isComponentName(codePathFunctionName) || - isHook(codePathFunctionName) + isHook(codePathFunctionName) : isForwardRefCallback(codePathNode) || isMemoCallback(codePathNode); // Compute the earliest finalizer level using information from the @@ -679,9 +707,9 @@ const rule = { 'same order in every component render.' + (possiblyHasEarlyReturn ? ' Did you accidentally call a React Hook after an' + - ' early return?' + ' early return?' : ''); - context.report({node: hook, message}); + context.report({ node: hook, message }); } } else if ( codePathNode.parent != null && @@ -689,7 +717,8 @@ const rule = { // @ts-expect-error `ClassProperty` was removed from typescript-estree in https://github.com/typescript-eslint/typescript-eslint/pull/3806 codePathNode.parent.type === 'ClassProperty' || codePathNode.parent.type === 'PropertyDefinition') && - codePathNode.parent.value === codePathNode + codePathNode.parent.value === codePathNode && + isReactComponentClass(codePathNode.parent) ) { // Custom message for hooks inside a class const message = @@ -698,7 +727,7 @@ const rule = { )}" 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}); + context.report({ node: hook, message }); } else if (codePathFunctionName) { // Custom message if we found an invalid function name. const message = @@ -708,7 +737,7 @@ const rule = { '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}); + context.report({ node: hook, message }); } else if (codePathNode.type === 'Program') { // These are dangerous if you have inline requires enabled. const message = @@ -717,7 +746,7 @@ const rule = { )}" 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}); + 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 @@ -732,7 +761,7 @@ const rule = { )}" 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}); + context.report({ node: hook, message }); } } }