[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.
Joseph Savona committed
Jan 21, 2026 at 12:49 UTC
03ee29da2f3a7dcd1a7a22ed9b4d13eeceb9da57
4 files changed
+390
packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleFlow-test.ts
new
+147
@@ -0,0 +1,147 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+import {RuleTester} from 'eslint';
9
+import {allRules} from '../src/shared/ReactCompiler';
10
+
11
+const ESLintTesterV8 = require('eslint-v8').RuleTester;
12
+
13
+/**
14
+ * A string template tag that removes padding from the left side of multi-line strings
15
+ * @param {Array} strings array of code strings (only one expected)
16
+ */
17
+function normalizeIndent(strings: TemplateStringsArray): string {
18
+ const codeLines = strings[0]?.split('\n') ?? [];
19
+ const leftPadding = codeLines[1]?.match(/\s+/)![0] ?? '';
20
+ return codeLines.map(line => line.slice(leftPadding.length)).join('\n');
21
+}
22
+
23
+type CompilerTestCases = {
24
+ valid: RuleTester.ValidTestCase[];
25
+ invalid: RuleTester.InvalidTestCase[];
26
+};
27
+
28
+const tests: CompilerTestCases = {
29
+ valid: [
30
+ // ===========================================
31
+ // Tests for mayContainReactCode heuristic with Flow syntax
32
+ // Files that should be SKIPPED (no React-like function names)
33
+ // These contain code that WOULD trigger errors if compiled,
34
+ // but since the heuristic skips them, no errors are reported.
35
+ // ===========================================
36
+ {
37
+ name: '[Heuristic/Flow] Skips files with only lowercase utility functions',
38
+ filename: 'utils.js',
39
+ code: normalizeIndent`
40
+ function helper(obj) {
41
+ obj.key = 'value';
42
+ return obj;
43
+ }
44
+ `,
45
+ },
46
+ {
47
+ name: '[Heuristic/Flow] Skips lowercase arrow functions even with mutations',
48
+ filename: 'helpers.js',
49
+ code: normalizeIndent`
50
+ const processData = (input) => {
51
+ input.modified = true;
52
+ return input;
53
+ };
54
+ `,
55
+ },
56
+ ],
57
+ invalid: [
58
+ // ===========================================
59
+ // Tests for mayContainReactCode heuristic with Flow component/hook syntax
60
+ // These use Flow's component/hook declarations which should be detected
61
+ // ===========================================
62
+ {
63
+ name: '[Heuristic/Flow] Compiles Flow component declaration - detects prop mutation',
64
+ filename: 'component.js',
65
+ code: normalizeIndent`
66
+ component MyComponent(a: {key: string}) {
67
+ a.key = 'value';
68
+ return <div />;
69
+ }
70
+ `,
71
+ errors: [
72
+ {
73
+ message: /Modifying component props/,
74
+ },
75
+ ],
76
+ },
77
+ {
78
+ name: '[Heuristic/Flow] Compiles exported Flow component declaration - detects prop mutation',
79
+ filename: 'component.js',
80
+ code: normalizeIndent`
81
+ export component MyComponent(a: {key: string}) {
82
+ a.key = 'value';
83
+ return <div />;
84
+ }
85
+ `,
86
+ errors: [
87
+ {
88
+ message: /Modifying component props/,
89
+ },
90
+ ],
91
+ },
92
+ {
93
+ name: '[Heuristic/Flow] Compiles default exported Flow component declaration - detects prop mutation',
94
+ filename: 'component.js',
95
+ code: normalizeIndent`
96
+ export default component MyComponent(a: {key: string}) {
97
+ a.key = 'value';
98
+ return <div />;
99
+ }
100
+ `,
101
+ errors: [
102
+ {
103
+ message: /Modifying component props/,
104
+ },
105
+ ],
106
+ },
107
+ {
108
+ name: '[Heuristic/Flow] Compiles Flow hook declaration - detects argument mutation',
109
+ filename: 'hooks.js',
110
+ code: normalizeIndent`
111
+ hook useMyHook(a: {key: string}) {
112
+ a.key = 'value';
113
+ return a;
114
+ }
115
+ `,
116
+ errors: [
117
+ {
118
+ message: /Modifying component props or hook arguments/,
119
+ },
120
+ ],
121
+ },
122
+ {
123
+ name: '[Heuristic/Flow] Compiles exported Flow hook declaration - detects argument mutation',
124
+ filename: 'hooks.js',
125
+ code: normalizeIndent`
126
+ export hook useMyHook(a: {key: string}) {
127
+ a.key = 'value';
128
+ return a;
129
+ }
130
+ `,
131
+ errors: [
132
+ {
133
+ message: /Modifying component props or hook arguments/,
134
+ },
135
+ ],
136
+ },
137
+ ],
138
+};
139
+
140
+const eslintTester = new ESLintTesterV8({
141
+ parser: require.resolve('hermes-eslint'),
142
+ parserOptions: {
143
+ sourceType: 'module',
144
+ enableExperimentalComponentSyntax: true,
145
+ },
146
+});
147
+eslintTester.run('react-compiler', allRules['immutability'].rule, tests);
packages/eslint-plugin-react-hooks/__tests__/ReactCompilerRuleTypescript-test.ts
+124
@@ -46,6 +46,35 @@ const tests: CompilerTestCases = {
46
}
47
`,
48
},
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
+ },
78
],
79
invalid: [
80
{
@@ -68,6 +97,101 @@ const tests: CompilerTestCases = {
97
},
98
],
99
},
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
+ },
195
],
196
};
197
packages/eslint-plugin-react-hooks/jest.config.js
+4
@@ -5,4 +5,8 @@ process.env.NODE_ENV = 'development';
5
module.exports = {
6
setupFiles: [require.resolve('../../scripts/jest/setupEnvironment.js')],
7
moduleFileExtensions: ['ts', 'js', 'json'],
8
+ moduleNameMapper: {
9
+ '^babel-plugin-react-compiler$':
10
+ '<rootDir>/../../compiler/packages/babel-plugin-react-compiler/dist/index.js',
11
+ },
12
};
packages/eslint-plugin-react-hooks/src/shared/RunReactCompiler.ts
+115
@@ -17,10 +17,107 @@ import BabelPluginReactCompiler, {
17
LoggerEvent,
18
} from 'babel-plugin-react-compiler';
19
import type {SourceCode} from 'eslint';
20
+import type * as ESTree from 'estree';
21
import * as HermesParser from 'hermes-parser';
22
import {isDeepStrictEqual} from 'util';
23
import type {ParseResult} from '@babel/parser';
24
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 Flow component/hook declarations (hermes-eslint produces these node types)
51
+ // @ts-expect-error not part of ESTree spec
52
+ if (node.type === 'ComponentDeclaration' || node.type === 'HookDeclaration') {
53
+ return true;
54
+ }
55
+
56
+ // Handle: export function MyComponent() {} or export const useHook = () => {}
57
+ if (node.type === 'ExportNamedDeclaration') {
58
+ const decl = (node as ESTree.ExportNamedDeclaration).declaration;
59
+ if (decl != null) {
60
+ return checkTopLevelNode(decl);
61
+ }
62
+ return false;
63
+ }
64
+
65
+ // Handle: export default function MyComponent() {} or export default () => {}
66
+ if (node.type === 'ExportDefaultDeclaration') {
67
+ const decl = (node as ESTree.ExportDefaultDeclaration).declaration;
68
+ // Anonymous default function export - compile conservatively
69
+ if (
70
+ decl.type === 'FunctionExpression' ||
71
+ decl.type === 'ArrowFunctionExpression' ||
72
+ (decl.type === 'FunctionDeclaration' &&
73
+ (decl as ESTree.FunctionDeclaration).id == null)
74
+ ) {
75
+ return true;
76
+ }
77
+ return checkTopLevelNode(decl as ESTree.Node);
78
+ }
79
+
80
+ // Handle: function MyComponent() {}
81
+ // Also handles Flow component/hook syntax transformed to FunctionDeclaration with flags
82
+ if (node.type === 'FunctionDeclaration') {
83
+ // Check for Hermes-added flags indicating Flow component/hook syntax
84
+ if (
85
+ '__componentDeclaration' in node ||
86
+ '__hookDeclaration' in node
87
+ ) {
88
+ return true;
89
+ }
90
+ const id = (node as ESTree.FunctionDeclaration).id;
91
+ if (id != null) {
92
+ const name = id.name;
93
+ if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
94
+ return true;
95
+ }
96
+ }
97
+ }
98
+
99
+ // Handle: const MyComponent = () => {} or const useHook = function() {}
100
+ if (node.type === 'VariableDeclaration') {
101
+ for (const decl of (node as ESTree.VariableDeclaration).declarations) {
102
+ if (decl.id.type === 'Identifier') {
103
+ const init = decl.init;
104
+ if (
105
+ init != null &&
106
+ (init.type === 'ArrowFunctionExpression' ||
107
+ init.type === 'FunctionExpression')
108
+ ) {
109
+ const name = decl.id.name;
110
+ if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
111
+ return true;
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return false;
119
+}
120
+
121
const COMPILER_OPTIONS: PluginOptions = {
122
outputMode: 'lint',
123
panicThreshold: 'none',
@@ -216,6 +313,24 @@ export default function runReactCompiler({
313
return entry;
314
}
315
316
+ // Quick heuristic: skip files that don't appear to contain React code.
317
+ // We still cache the empty result so subsequent rules don't re-run the check.
318
+ if (!mayContainReactCode(sourceCode)) {
319
+ const emptyResult: RunCacheEntry = {
320
+ sourceCode: sourceCode.text,
321
+ filename,
322
+ userOpts,
323
+ flowSuppressions: [],
324
+ events: [],
325
+ };
326
+ if (entry != null) {
327
+ Object.assign(entry, emptyResult);
328
+ } else {
329
+ cache.push(filename, emptyResult);
330
+ }
331
+ return {...emptyResult};
332
+ }
333
+
334
const runEntry = runReactCompilerImpl({
335
sourceCode,
336
filename,