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$': + '/../../compiler/packages/babel-plugin-react-compiler/dist/index.js', + }, }; diff --git a/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts b/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts index acd50a36816..9aaddb07e65 100644 --- a/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts +++ b/packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts @@ -17,10 +17,107 @@ import BabelPluginReactCompiler, { LoggerEvent, } from 'babel-plugin-react-compiler'; import type {SourceCode} from 'eslint'; +import type * as ESTree from 'estree'; import * as HermesParser from 'hermes-parser'; import {isDeepStrictEqual} from 'util'; import type {ParseResult} from '@babel/parser'; +// Pattern for component names: starts with uppercase letter +const COMPONENT_NAME_PATTERN = /^[A-Z]/; +// Pattern for hook names: starts with 'use' followed by uppercase letter or digit +const HOOK_NAME_PATTERN = /^use[A-Z0-9]/; + +/** + * Quick heuristic using ESLint's already-parsed AST to detect if the file + * may contain React components or hooks based on function naming patterns. + * Only checks top-level declarations since components/hooks are declared at module scope. + * Returns true if compilation should proceed, false to skip. + */ +function mayContainReactCode(sourceCode: SourceCode): boolean { + const ast = sourceCode.ast; + + // Only check top-level statements - components/hooks are declared at module scope + for (const node of ast.body) { + if (checkTopLevelNode(node)) { + return true; + } + } + + return false; +} + +function checkTopLevelNode(node: ESTree.Node): boolean { + // Handle Flow component/hook declarations (hermes-eslint produces these node types) + // @ts-expect-error not part of ESTree spec + if (node.type === 'ComponentDeclaration' || node.type === 'HookDeclaration') { + return true; + } + + // Handle: export function MyComponent() {} or export const useHook = () => {} + if (node.type === 'ExportNamedDeclaration') { + const decl = (node as ESTree.ExportNamedDeclaration).declaration; + if (decl != null) { + return checkTopLevelNode(decl); + } + return false; + } + + // Handle: export default function MyComponent() {} or export default () => {} + if (node.type === 'ExportDefaultDeclaration') { + const decl = (node as ESTree.ExportDefaultDeclaration).declaration; + // Anonymous default function export - compile conservatively + if ( + decl.type === 'FunctionExpression' || + decl.type === 'ArrowFunctionExpression' || + (decl.type === 'FunctionDeclaration' && + (decl as ESTree.FunctionDeclaration).id == null) + ) { + return true; + } + return checkTopLevelNode(decl as ESTree.Node); + } + + // Handle: function MyComponent() {} + // Also handles Flow component/hook syntax transformed to FunctionDeclaration with flags + if (node.type === 'FunctionDeclaration') { + // Check for Hermes-added flags indicating Flow component/hook syntax + if ( + '__componentDeclaration' in node || + '__hookDeclaration' in node + ) { + return true; + } + const id = (node as ESTree.FunctionDeclaration).id; + if (id != null) { + const name = id.name; + if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) { + return true; + } + } + } + + // Handle: const MyComponent = () => {} or const useHook = function() {} + if (node.type === 'VariableDeclaration') { + for (const decl of (node as ESTree.VariableDeclaration).declarations) { + if (decl.id.type === 'Identifier') { + const init = decl.init; + if ( + init != null && + (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 false; +} + const COMPILER_OPTIONS: PluginOptions = { outputMode: 'lint', panicThreshold: 'none', @@ -216,6 +313,24 @@ export default function runReactCompiler({ return entry; } + // Quick heuristic: skip files that don't appear to contain React code. + // We still cache the empty result so subsequent rules don't re-run the check. + if (!mayContainReactCode(sourceCode)) { + const emptyResult: RunCacheEntry = { + sourceCode: sourceCode.text, + filename, + userOpts, + flowSuppressions: [], + events: [], + }; + if (entry != null) { + Object.assign(entry, emptyResult); + } else { + cache.push(filename, emptyResult); + } + return {...emptyResult}; + } + const runEntry = runReactCompilerImpl({ sourceCode, filename,