[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.
This commit is contained in:
Joseph Savona
2026-01-21 12:49:15 -08:00
committed by GitHub
parent cdbd55f440
commit 03ee29da2f
4 changed files with 390 additions and 0 deletions

View File

@@ -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 <div />;
}
`,
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 <div />;
}
`,
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 <div />;
}
`,
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);

View File

@@ -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 <div />;
}
`,
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 <div />;
};
`,
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 <div />;
};
`,
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 <div />;
}
`,
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 <div />;
};
`,
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 <div />;
}
`,
errors: [
{
message: /Modifying component props/,
},
],
},
],
};

View File

@@ -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$':
'<rootDir>/../../compiler/packages/babel-plugin-react-compiler/dist/index.js',
},
};

View File

@@ -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,