From 03ee29da2f3a7dcd1a7a22ed9b4d13eeceb9da57 Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:49:15 -0800 Subject: [PATCH] [eslint-plugin-react-hooks] Skip compilation for non-React files (#35589) Add a fast heuristic to detect whether a file may contain React components or hooks before running the full compiler. This avoids the overhead of Babel AST parsing and compilation for utility files, config files, and other non-React code. The heuristic uses ESLint's already-parsed AST to check for functions with React-like names at module scope: - Capitalized functions: MyComponent, Button, App - Hook pattern functions: useEffect, useState, useMyCustomHook Files without matching function names are skipped and return an empty result, which is cached to avoid re-checking for subsequent rules. Also adds test coverage for the heuristic edge cases. --- .../__tests__/ReactCompilerRuleFlow-test.ts | 147 ++++++++++++++++++ .../ReactCompilerRuleTypescript-test.ts | 124 +++++++++++++++ .../eslint-plugin-react-hooks/jest.config.js | 4 + .../src/shared/RunReactCompiler.ts | 115 ++++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts diff --git a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts new file mode 100644 index 00000000000..29d1437f86c --- /dev/null +++ b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {RuleTester} from 'eslint'; +import {allRules} from '../src/shared/ReactCompiler'; + +const ESLintTesterV8 = require('eslint-v8').RuleTester; + +/** + * A string template tag that removes padding from the left side of multi-line strings + * @param {Array} strings array of code strings (only one expected) + */ +function normalizeIndent(strings: TemplateStringsArray): string { + const codeLines = strings[0]?.split('\n') ?? []; + const leftPadding = codeLines[1]?.match(/\s+/)![0] ?? ''; + return codeLines.map(line => line.slice(leftPadding.length)).join('\n'); +} + +type CompilerTestCases = { + valid: RuleTester.ValidTestCase[]; + invalid: RuleTester.InvalidTestCase[]; +}; + +const tests: CompilerTestCases = { + valid: [ + // =========================================== + // Tests for mayContainReactCode heuristic with Flow syntax + // Files that should be SKIPPED (no React-like function names) + // These contain code that WOULD trigger errors if compiled, + // but since the heuristic skips them, no errors are reported. + // =========================================== + { + name: '[Heuristic/Flow] Skips files with only lowercase utility functions', + filename: 'utils.js', + code: normalizeIndent` + function helper(obj) { + obj.key = 'value'; + return obj; + } + `, + }, + { + name: '[Heuristic/Flow] Skips lowercase arrow functions even with mutations', + filename: 'helpers.js', + code: normalizeIndent` + const processData = (input) => { + input.modified = true; + return input; + }; + `, + }, + ], + invalid: [ + // =========================================== + // Tests for mayContainReactCode heuristic with Flow component/hook syntax + // These use Flow's component/hook declarations which should be detected + // =========================================== + { + name: '[Heuristic/Flow] Compiles Flow component declaration - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + component MyComponent(a: {key: string}) { + a.key = 'value'; + return
; + } + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles exported Flow component declaration - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + export component MyComponent(a: {key: string}) { + a.key = 'value'; + return ; + } + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles default exported Flow component declaration - detects prop mutation', + filename: 'component.js', + code: normalizeIndent` + export default component MyComponent(a: {key: string}) { + a.key = 'value'; + return ; + } + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles Flow hook declaration - detects argument mutation', + filename: 'hooks.js', + code: normalizeIndent` + hook useMyHook(a: {key: string}) { + a.key = 'value'; + return a; + } + `, + errors: [ + { + message: /Modifying component props or hook arguments/, + }, + ], + }, + { + name: '[Heuristic/Flow] Compiles exported Flow hook declaration - detects argument mutation', + filename: 'hooks.js', + code: normalizeIndent` + export hook useMyHook(a: {key: string}) { + a.key = 'value'; + return a; + } + `, + errors: [ + { + message: /Modifying component props or hook arguments/, + }, + ], + }, + ], +}; + +const eslintTester = new ESLintTesterV8({ + parser: require.resolve('hermes-eslint'), + parserOptions: { + sourceType: 'module', + enableExperimentalComponentSyntax: true, + }, +}); +eslintTester.run('react-compiler', allRules['immutability'].rule, tests); diff --git a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts index 1da7be666af..a0d0f6bdbc8 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts +++ b/packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts @@ -46,6 +46,35 @@ const tests: CompilerTestCases = { } `, }, + // =========================================== + // Tests for mayContainReactCode heuristic + // Files that should be SKIPPED (no React-like function names) + // These contain code that WOULD trigger errors if compiled, + // but since the heuristic skips them, no errors are reported. + // =========================================== + { + name: '[Heuristic] Skips files with only lowercase utility functions', + filename: 'utils.ts', + // This mutates an argument, which would be flagged in a component/hook, + // but this file is skipped because there are no React-like function names + code: normalizeIndent` + function helper(obj) { + obj.key = 'value'; + return obj; + } + `, + }, + { + name: '[Heuristic] Skips lowercase arrow functions even with mutations', + filename: 'helpers.ts', + // Would be flagged if compiled, but skipped due to lowercase name + code: normalizeIndent` + const processData = (input) => { + input.modified = true; + return input; + }; + `, + }, ], invalid: [ { @@ -68,6 +97,101 @@ const tests: CompilerTestCases = { }, ], }, + // =========================================== + // Tests for mayContainReactCode heuristic + // Files that SHOULD be compiled (have React-like function names) + // These contain violations to prove compilation happens. + // =========================================== + { + name: '[Heuristic] Compiles PascalCase function declaration - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + function MyComponent({a}) { + a.key = 'value'; + return ; + } + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles PascalCase arrow function - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + const MyComponent = ({a}) => { + a.key = 'value'; + return ; + }; + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles PascalCase function expression - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + const MyComponent = function({a}) { + a.key = 'value'; + return ; + }; + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles exported function declaration - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + export function MyComponent({a}) { + a.key = 'value'; + return ; + } + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles exported arrow function - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + export const MyComponent = ({a}) => { + a.key = 'value'; + return ; + }; + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, + { + name: '[Heuristic] Compiles default exported function - detects prop mutation', + filename: 'component.tsx', + code: normalizeIndent` + export default function MyComponent({a}) { + a.key = 'value'; + return ; + } + `, + errors: [ + { + message: /Modifying component props/, + }, + ], + }, ], }; diff --git a/packages/eslint-plugin-react-hooks/jest.config.js b/packages/eslint-plugin-react-hooks/jest.config.js index a7b91c3ef1c..aa501cd4261 100644 --- a/packages/eslint-plugin-react-hooks/jest.config.js +++ b/packages/eslint-plugin-react-hooks/jest.config.js @@ -5,4 +5,8 @@ process.env.NODE_ENV = 'development'; module.exports = { setupFiles: [require.resolve('../../scripts/jest/setupEnvironment.js')], moduleFileExtensions: ['ts', 'js', 'json'], + moduleNameMapper: { + '^babel-plugin-react-compiler$': + '