Skip to content

Commit bcdfcec

Browse files
committed
[eslint-plugin-react-hooks] Skip compilation for non-React files
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 names: MyComponent, Button, App - Hook-like names: 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.
1 parent d290875 commit bcdfcec

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

β€Žpackages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.tsβ€Ž

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,35 @@ const tests: CompilerTestCases = {
4646
}
4747
`,
4848
},
49+
// ===========================================
50+
// Tests for mayContainReactCode heuristic
51+
// Files that should be SKIPPED (no React-like function names)
52+
// These contain code that WOULD trigger errors if compiled,
53+
// but since the heuristic skips them, no errors are reported.
54+
// ===========================================
55+
{
56+
name: '[Heuristic] Skips files with only lowercase utility functions',
57+
filename: 'utils.ts',
58+
// This mutates an argument, which would be flagged in a component/hook,
59+
// but this file is skipped because there are no React-like function names
60+
code: normalizeIndent`
61+
function helper(obj) {
62+
obj.key = 'value';
63+
return obj;
64+
}
65+
`,
66+
},
67+
{
68+
name: '[Heuristic] Skips lowercase arrow functions even with mutations',
69+
filename: 'helpers.ts',
70+
// Would be flagged if compiled, but skipped due to lowercase name
71+
code: normalizeIndent`
72+
const processData = (input) => {
73+
input.modified = true;
74+
return input;
75+
};
76+
`,
77+
},
4978
],
5079
invalid: [
5180
{
@@ -68,6 +97,101 @@ const tests: CompilerTestCases = {
6897
},
6998
],
7099
},
100+
// ===========================================
101+
// Tests for mayContainReactCode heuristic
102+
// Files that SHOULD be compiled (have React-like function names)
103+
// These contain violations to prove compilation happens.
104+
// ===========================================
105+
{
106+
name: '[Heuristic] Compiles PascalCase function declaration - detects prop mutation',
107+
filename: 'component.tsx',
108+
code: normalizeIndent`
109+
function MyComponent({a}) {
110+
a.key = 'value';
111+
return <div />;
112+
}
113+
`,
114+
errors: [
115+
{
116+
message: /Modifying component props/,
117+
},
118+
],
119+
},
120+
{
121+
name: '[Heuristic] Compiles PascalCase arrow function - detects prop mutation',
122+
filename: 'component.tsx',
123+
code: normalizeIndent`
124+
const MyComponent = ({a}) => {
125+
a.key = 'value';
126+
return <div />;
127+
};
128+
`,
129+
errors: [
130+
{
131+
message: /Modifying component props/,
132+
},
133+
],
134+
},
135+
{
136+
name: '[Heuristic] Compiles PascalCase function expression - detects prop mutation',
137+
filename: 'component.tsx',
138+
code: normalizeIndent`
139+
const MyComponent = function({a}) {
140+
a.key = 'value';
141+
return <div />;
142+
};
143+
`,
144+
errors: [
145+
{
146+
message: /Modifying component props/,
147+
},
148+
],
149+
},
150+
{
151+
name: '[Heuristic] Compiles exported function declaration - detects prop mutation',
152+
filename: 'component.tsx',
153+
code: normalizeIndent`
154+
export function MyComponent({a}) {
155+
a.key = 'value';
156+
return <div />;
157+
}
158+
`,
159+
errors: [
160+
{
161+
message: /Modifying component props/,
162+
},
163+
],
164+
},
165+
{
166+
name: '[Heuristic] Compiles exported arrow function - detects prop mutation',
167+
filename: 'component.tsx',
168+
code: normalizeIndent`
169+
export const MyComponent = ({a}) => {
170+
a.key = 'value';
171+
return <div />;
172+
};
173+
`,
174+
errors: [
175+
{
176+
message: /Modifying component props/,
177+
},
178+
],
179+
},
180+
{
181+
name: '[Heuristic] Compiles default exported function - detects prop mutation',
182+
filename: 'component.tsx',
183+
code: normalizeIndent`
184+
export default function MyComponent({a}) {
185+
a.key = 'value';
186+
return <div />;
187+
}
188+
`,
189+
errors: [
190+
{
191+
message: /Modifying component props/,
192+
},
193+
],
194+
},
71195
],
72196
};
73197

β€Žpackages/eslint-plugin-react-hooks/jest.config.jsβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ process.env.NODE_ENV = 'development';
55
module.exports = {
66
setupFiles: [require.resolve('../../scripts/jest/setupEnvironment.js')],
77
moduleFileExtensions: ['ts', 'js', 'json'],
8+
moduleNameMapper: {
9+
'^babel-plugin-react-compiler$':
10+
'<rootDir>/../../compiler/packages/babel-plugin-react-compiler/dist/index.js',
11+
},
812
};

β€Žpackages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.tsβ€Ž

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,93 @@ import BabelPluginReactCompiler, {
1717
LoggerEvent,
1818
} from 'babel-plugin-react-compiler';
1919
import type {SourceCode} from 'eslint';
20+
import type * as ESTree from 'estree';
2021
import * as HermesParser from 'hermes-parser';
2122
import {isDeepStrictEqual} from 'util';
2223
import type {ParseResult} from '@babel/parser';
2324

25+
// Pattern for component names: starts with uppercase letter
26+
const COMPONENT_NAME_PATTERN = /^[A-Z]/;
27+
// Pattern for hook names: starts with 'use' followed by uppercase letter or digit
28+
const HOOK_NAME_PATTERN = /^use[A-Z0-9]/;
29+
30+
/**
31+
* Quick heuristic using ESLint's already-parsed AST to detect if the file
32+
* may contain React components or hooks based on function naming patterns.
33+
* Only checks top-level declarations since components/hooks are declared at module scope.
34+
* Returns true if compilation should proceed, false to skip.
35+
*/
36+
function mayContainReactCode(sourceCode: SourceCode): boolean {
37+
const ast = sourceCode.ast;
38+
39+
// Only check top-level statements - components/hooks are declared at module scope
40+
for (const node of ast.body) {
41+
if (checkTopLevelNode(node)) {
42+
return true;
43+
}
44+
}
45+
46+
return false;
47+
}
48+
49+
function checkTopLevelNode(node: ESTree.Node): boolean {
50+
// Handle: export function MyComponent() {} or export const useHook = () => {}
51+
if (node.type === 'ExportNamedDeclaration') {
52+
const decl = (node as ESTree.ExportNamedDeclaration).declaration;
53+
if (decl != null) {
54+
return checkTopLevelNode(decl);
55+
}
56+
return false;
57+
}
58+
59+
// Handle: export default function MyComponent() {} or export default () => {}
60+
if (node.type === 'ExportDefaultDeclaration') {
61+
const decl = (node as ESTree.ExportDefaultDeclaration).declaration;
62+
// Anonymous default function export - compile conservatively
63+
if (
64+
decl.type === 'FunctionExpression' ||
65+
decl.type === 'ArrowFunctionExpression' ||
66+
(decl.type === 'FunctionDeclaration' &&
67+
(decl as ESTree.FunctionDeclaration).id == null)
68+
) {
69+
return true;
70+
}
71+
return checkTopLevelNode(decl as ESTree.Node);
72+
}
73+
74+
// Handle: function MyComponent() {}
75+
if (node.type === 'FunctionDeclaration') {
76+
const id = (node as ESTree.FunctionDeclaration).id;
77+
if (id != null) {
78+
const name = id.name;
79+
if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
80+
return true;
81+
}
82+
}
83+
}
84+
85+
// Handle: const MyComponent = () => {} or const useHook = function() {}
86+
if (node.type === 'VariableDeclaration') {
87+
for (const decl of (node as ESTree.VariableDeclaration).declarations) {
88+
if (decl.id.type === 'Identifier') {
89+
const init = decl.init;
90+
if (
91+
init != null &&
92+
(init.type === 'ArrowFunctionExpression' ||
93+
init.type === 'FunctionExpression')
94+
) {
95+
const name = decl.id.name;
96+
if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
97+
return true;
98+
}
99+
}
100+
}
101+
}
102+
}
103+
104+
return false;
105+
}
106+
24107
const COMPILER_OPTIONS: PluginOptions = {
25108
outputMode: 'lint',
26109
panicThreshold: 'none',
@@ -216,6 +299,24 @@ export default function runReactCompiler({
216299
return entry;
217300
}
218301

302+
// Quick heuristic: skip files that don't appear to contain React code.
303+
// We still cache the empty result so subsequent rules don't re-run the check.
304+
if (!mayContainReactCode(sourceCode)) {
305+
const emptyResult: RunCacheEntry = {
306+
sourceCode: sourceCode.text,
307+
filename,
308+
userOpts,
309+
flowSuppressions: [],
310+
events: [],
311+
};
312+
if (entry != null) {
313+
Object.assign(entry, emptyResult);
314+
} else {
315+
cache.push(filename, emptyResult);
316+
}
317+
return {...emptyResult};
318+
}
319+
219320
const runEntry = runReactCompilerImpl({
220321
sourceCode,
221322
filename,

0 commit comments

Comments
 (0)