From 98e06a879a138a7f1679f067adf0a2a234cb89ba Mon Sep 17 00:00:00 2001 From: fresh3nough Date: Mon, 2 Mar 2026 14:18:55 +0000 Subject: [PATCH] fix: detect setState in useEffect for anonymous component callbacks passed to HOFs (#35910) The react-hooks/set-state-in-effect ESLint rule was not detecting setState calls inside useEffect when the component was an anonymous callback passed to a higher-order function (e.g. const MyComponent = wrap(() => { ... })). Root cause: getFunctionName() in the compiler did not handle the HOF pattern (parent is CallExpression whose parent is VariableDeclarator), so the anonymous function was never identified as a component and never compiled/validated. Changes: - Add HOF pattern handling to getFunctionName() in Program.ts - Add CallExpression to the mayContainReactCode heuristic in RunReactCompiler.ts so HOF-wrapped components are not skipped - Add test cases for both set-state-in-effect and prop mutation detection in HOF-wrapped components Fixes: https://github.com/facebook/react/issues/35910 --- .../src/Entrypoint/Program.ts | 7 ++ .../ReactCompilerRuleTypescript-test.ts | 84 +++++++++++++++++++ .../src/shared/RunReactCompiler.ts | 4 +- 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts index 80ce909f35aa..8016e43597c7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -1324,6 +1324,13 @@ function getFunctionName( if (parent.isVariableDeclarator() && parent.get('init').node === path.node) { // const useHook = () => {}; id = parent.get('id'); + } else if ( + parent.isCallExpression() && + parent.parentPath.isVariableDeclarator() && + parent.parentPath.get('init').node === parent.node + ) { + // const MyComponent = wrap(() => {}); + id = parent.parentPath.get('id'); } else if ( parent.isAssignmentExpression() && parent.get('right').node === path.node && diff --git a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts index a0d0f6bdbc8e..7c562572d935 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts +++ b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts @@ -192,6 +192,22 @@ const tests: CompilerTestCases = { }, ], }, + { + name: '[Heuristic] Compiles HOF-wrapped PascalCase component - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + const wrap = (value) => value; + const MyComponent = wrap(({a}) => { + a.key = 'value'; + return
; + }); + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, ], }; @@ -199,3 +215,71 @@ const eslintTester = new ESLintTesterV8({ parser: require.resolve('@typescript-eslint/parser-v5'), }); eslintTester.run('react-compiler', allRules['immutability'].rule, tests); + +// Tests for set-state-in-effect rule with HOF-wrapped components +// Reproduction test for https://github.com/facebook/react/issues/35910 +const setStateInEffectTests: CompilerTestCases = { + valid: [ + { + name: 'Direct component with no setState in effect', + filename: 'test.tsx', + code: normalizeIndent` + import { useEffect, useState } from 'react'; + function DirectComponent() { + const [value, setValue] = useState(0); + useEffect(() => { + console.log(value); + }, []); + return
{value}
; + } + `, + }, + ], + invalid: [ + { + name: 'Direct component with setState in effect', + filename: 'test.tsx', + code: normalizeIndent` + import { useEffect, useState } from 'react'; + function DirectComponent() { + const [value, setValue] = useState(0); + useEffect(() => { + setValue(1); + }, []); + return
{value}
; + } + `, + errors: [ + { + message: /Calling setState synchronously within an effect/, + }, + ], + }, + { + name: 'Anonymous component callback passed to HOF has setState in effect', + filename: 'test.tsx', + code: normalizeIndent` + import { useEffect, useState } from 'react'; + const wrap = (value) => value; + const WrappedComponent = wrap(() => { + const [value, setValue] = useState(0); + useEffect(() => { + setValue(1); + }, []); + return
{value}
; + }); + `, + errors: [ + { + message: /Calling setState synchronously within an effect/, + }, + ], + }, + ], +}; + +eslintTester.run( + 'react-compiler-set-state-in-effect', + allRules['set-state-in-effect'].rule, + setStateInEffectTests, +); diff --git a/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts b/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts index 9aaddb07e656..8dcaf70365c5 100644 --- a/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts +++ b/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts @@ -97,6 +97,7 @@ function checkTopLevelNode(node: ESTree.Node): boolean { } // Handle: const MyComponent = () => {} or const useHook = function() {} + // Also handles: const MyComponent = wrap(() => {}) if (node.type === 'VariableDeclaration') { for (const decl of (node as ESTree.VariableDeclaration).declarations) { if (decl.id.type === 'Identifier') { @@ -104,7 +105,8 @@ function checkTopLevelNode(node: ESTree.Node): boolean { if ( init != null && (init.type === 'ArrowFunctionExpression' || - init.type === 'FunctionExpression') + init.type === 'FunctionExpression' || + init.type === 'CallExpression') ) { const name = decl.id.name; if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {