@samitouri / QOS-React / commits / 03ee29da2f

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