mirror of
https://github.com/facebook/react.git
synced 2026-02-22 03:42:05 +00:00
[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:
@@ -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);
|
||||
@@ -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/,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user