diff --git a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts index 29d1437f86cd..7a9d665123ab 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts +++ b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts @@ -53,6 +53,18 @@ const tests: CompilerTestCases = { }; `, }, + { + name: '[Heuristic/Flow] Skips lowercase variable initialized with call expression wrapping a function', + filename: 'utils.js', + // Lowercase name means the heuristic skips this file entirely, + // even though the init is a call expression wrapping a function. + code: normalizeIndent` + const helper = someWrapper(function(obj) { + obj.key = 'value'; + return obj; + }); + `, + }, ], invalid: [ // =========================================== @@ -134,6 +146,87 @@ const tests: CompilerTestCases = { }, ], }, + // =========================================== + // Tests for HOC-wrapped components (memo, forwardRef, etc.) with Flow parser + // The heuristic must unwrap one level of CallExpression to detect these. + // Regression tests for: const Comp = memo(function Comp() {...}) being + // silently skipped while an equivalent plain function declaration was compiled. + // =========================================== + { + name: '[Heuristic/Flow] Compiles memo-wrapped named function expression - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + const MyComponent = memo(function MyComponent({a}) { + a.key = 'value'; + return
; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles memo-wrapped arrow function - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + const MyComponent = memo(({a}) => { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles React.memo-wrapped function expression - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + const MyComponent = React.memo(function MyComponent({a}) { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles forwardRef-wrapped function expression - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + const MyComponent = forwardRef(function MyComponent({a}, ref) { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles exported memo-wrapped component - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + export const MyComponent = memo(function MyComponent({a}) { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, ], }; diff --git a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts index a0d0f6bdbc8e..80674cbcbc01 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts +++ b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts @@ -75,6 +75,18 @@ const tests: CompilerTestCases = { }; `, }, + { + name: '[Heuristic] Skips lowercase variable initialized with call expression wrapping a function', + filename: 'utils.ts', + // Lowercase name means the heuristic skips this file entirely, + // even though the init is a call expression wrapping a function. + code: normalizeIndent` + const helper = someWrapper(function(obj) { + obj.key = 'value'; + return obj; + }); + `, + }, ], invalid: [ { @@ -192,6 +204,87 @@ const tests: CompilerTestCases = { }, ], }, + // =========================================== + // Tests for HOC-wrapped components (memo, forwardRef, etc.) + // The heuristic must unwrap one level of CallExpression to detect these. + // Regression tests for: const Comp = memo(function Comp() {...}) being + // silently skipped while an equivalent plain function declaration was compiled. + // =========================================== + { + name: '[Heuristic] Compiles memo-wrapped named function expression - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + const MyComponent = memo(function MyComponent({a}) { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles memo-wrapped arrow function - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + const MyComponent = memo(({a}) => { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles React.memo-wrapped function expression - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + const MyComponent = React.memo(function MyComponent({a}) { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles forwardRef-wrapped function expression - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + const MyComponent = forwardRef(function MyComponent({a}, ref) { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles exported memo-wrapped component - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + export const MyComponent = memo(function MyComponent({a}) { + a.key = 'value'; + return ; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, ], }; diff --git a/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts b/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts index 34151fe8c27e..aa0ad3f64592 100644 --- a/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts +++ b/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts @@ -99,21 +99,40 @@ function checkTopLevelNode(node: ESTree.Node): boolean { } // Handle: const MyComponent = () => {} or const useHook = function() {} + // Also handles: const MyComponent = memo(function MyComponent() {}) or + // const MyComponent = React.memo(() => {}) if (node.type === 'VariableDeclaration') { for (const decl of (node as ESTree.VariableDeclaration).declarations) { if (decl.id.type === 'Identifier') { + const name = decl.id.name; + if ( + !COMPONENT_NAME_PATTERN.test(name) && + !HOOK_NAME_PATTERN.test(name) + ) { + continue; + } const init = decl.init; + if (init == null) { + continue; + } if ( - init != null && - (init.type === 'ArrowFunctionExpression' || - init.type === 'FunctionExpression') + init.type === 'ArrowFunctionExpression' || + init.type === 'FunctionExpression' ) { - const name = decl.id.name; - if ( - COMPONENT_NAME_PATTERN.test(name) || - HOOK_NAME_PATTERN.test(name) - ) { - return true; + return true; + } + // Unwrap one level of call expression to catch patterns like: + // const Comp = memo(function Comp() {...}) + // const Comp = React.memo(() => {...}) + // const Comp = forwardRef(function Comp() {...}) + if (init.type === 'CallExpression') { + for (const arg of (init as ESTree.CallExpression).arguments) { + if ( + arg.type === 'ArrowFunctionExpression' || + arg.type === 'FunctionExpression' + ) { + return true; + } } } }