From 7587afa5ba9cbcb9f2d257128c7b54822fb7f627 Mon Sep 17 00:00:00 2001 From: Michael Altamirano Date: Thu, 7 May 2026 18:10:35 +0000 Subject: [PATCH 1/2] fix[eslint-plugin-react-hooks]: detect memo/forwardRef-wrapped components in mayContainReactCode heuristic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VariableDeclaration handler in checkTopLevelNode only matched ArrowFunctionExpression and FunctionExpression as initializers. This caused the heuristic to return false for patterns like: const MyComponent = memo(function MyComponent() { ... }) const MyComponent = React.memo(() => { ... }) const MyComponent = forwardRef(function MyComponent() { ... }) When mayContainReactCode returns false, compilation is skipped and no compiler-based rules (refs, set-state-in-effect, etc.) are reported — even though the identical violations in a plain function declaration are caught correctly. Fix: unwrap one level of CallExpression arguments so that a function literal passed to a HOC (memo, forwardRef, etc.) is treated the same as a direct function assignment when the variable name matches the component or hook naming convention. --- .../src/shared/RunReactCompiler.ts | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) 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; + } } } } From ff1241245c212add6fbb6249d60b5bb5f30e4c1b Mon Sep 17 00:00:00 2001 From: Michael Altamirano Date: Thu, 7 May 2026 18:24:36 +0000 Subject: [PATCH 2/2] test[eslint-plugin-react-hooks]: add heuristic coverage for HOC-wrapped components Add regression tests for memo/forwardRef-wrapped component patterns that were silently skipped by the mayContainReactCode heuristic before the fix to checkTopLevelNode's VariableDeclaration handler. Each test file gains: - invalid: memo(function Comp), memo(arrow), React.memo(function Comp), forwardRef(function Comp), export const Comp = memo(...) These prove compilation runs and violations are reported, matching the behavior of equivalent plain function declarations. - valid: lowercase variable initialized with a wrapped function This proves the heuristic still correctly skips non-component files when the variable name does not match the PascalCase/hook naming convention. --- .../__tests__/ReactCompilerRuleFlow-test.ts | 93 +++++++++++++++++++ .../ReactCompilerRuleTypescript-test.ts | 93 +++++++++++++++++++ 2 files changed, 186 insertions(+) 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/, + }, + ], + }, ], };