fix(linter): only run React Compiler rules on files with top-level components or hooks

Closes #27604
Closes #19957
This commit is contained in:
Boshen committed 2026-10-29 15:21:32 +08:00
1 parent 4c9d5aa496
commit c67167ef70
10 files changed
+174 -31

No files matched your search

@@ -0,0 +1,5 @@
{
"categories": { "correctness": "off" },
"plugins": ["react"],
"rules": { "react/globals": "error", "react/immutability": "error" }
}
@@ -0,0 +1,10 @@
import { useState } from "react";
let count = 0;
// A top-level hook declared with an arrow function is linted.
export const useCounter = () => {
const [step] = useState(1);
count = count + step;
return count;
};
@@ -0,0 +1,8 @@
let someGlobal = false;
// Only a `React.memo` call at the top level, so the file is skipped like in
// `eslint-plugin-react-hooks`.
export const Component = React.memo(function (props) {
someGlobal = true;
return <div>{props.text}</div>;
});
@@ -0,0 +1,9 @@
import { useState } from "react";
let result: number | null = null;
// A top-level component is linted.
export function Probe() {
result = useState(1)[0];
return null;
}
@@ -0,0 +1,13 @@
import { useState } from "react";
// No top-level component or hook, so the file is skipped like in
// `eslint-plugin-react-hooks`.
export function mountProbe(): number | null {
let result: number | null = null;
function Probe() {
result = useState(1)[0];
return null;
}
void Probe;
return result;
}
@@ -0,0 +1,36 @@
# Exit code
1
# stdout
```
x react(globals): Cannot reassign variables declared outside of the component/hook
,-[files/arrow-hook.js:8:3]
7 | const [step] = useState(1);
8 | count = count + step;
: ^^|^^
: `-- `count` cannot be reassigned
9 | return count;
`----
help: Variable `count` is declared outside of the component/hook. Reassigning this value during render is a side effect which can cause unpredictable behavior. If this variable is used in
rendering, use useState instead. Otherwise, update it in an effect
note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/globals
x react(globals): Cannot reassign variables declared outside of the component/hook
,-[files/module-level-probe.test.tsx:7:3]
6 | export function Probe() {
7 | result = useState(1)[0];
: ^^^|^^
: `-- `result` cannot be reassigned
8 | return null;
`----
help: Variable `result` is declared outside of the component/hook. Reassigning this value during render is a side effect which can cause unpredictable behavior. If this variable is used in
rendering, use useState instead. Otherwise, update it in an effect
note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/globals
Found 0 warnings and 2 errors.
Finished in Xms on 4 files with 2 rules using X threads.
```
# stderr
```
```
@@ -1,7 +1,12 @@
let someGlobal = false;
// An anonymous component wrapped in `React.memo` is linted.
// An anonymous component wrapped in `React.memo` is linted when the file also
// has a top-level component.
export const Component = React.memo(function (props) {
someGlobal = true;
return <div>{props.text}</div>;
});
export function App() {
return <Component text="hello" />;
}
@@ -4,12 +4,12 @@
# stdout
```
x react(globals): Cannot reassign variables declared outside of the component/hook
,-[files/memo.jsx:5:3]
4 | export const Component = React.memo(function (props) {
5 | someGlobal = true;
,-[files/memo.jsx:6:3]
5 | export const Component = React.memo(function (props) {
6 | someGlobal = true;
: ^^^^^|^^^^
: `-- `someGlobal` cannot be reassigned
6 | return <div>{props.text}</div>;
7 | return <div>{props.text}</div>;
`----
help: Variable `someGlobal` is declared outside of the component/hook. Reassigning this value during render is a side effect which can cause unpredictable behavior. If this variable is used in
rendering, use useState instead. Otherwise, update it in an effect
+61 -4
View File
@@ -1,6 +1,10 @@
use std::borrow::Cow;
use lazy_regex::Regex;
use oxc_ast::ast::{
BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, FunctionType, Program,
Statement,
};
use oxc_diagnostics::OxcCode;
use oxc_react_compiler::{
CompilerOutputMode, EnvironmentConfig, ErrorCategory, ExhaustiveEffectDepsMode, LintDiagnostic,
@@ -114,15 +118,68 @@ pub fn build_react_compiler_results(host: &ContextHost) -> ReactCompilerResults
}
/// Shared `should_run` for the React Compiler family: match the upstream
/// `node_modules` source filter and skip files with multiple `<script>` sections
/// (vue/astro/svelte). The latter is required for correctness, not just speed —
/// the cache on [`ContextHost`] is per file, so it would replay the first script
/// section's findings for every later section.
/// `node_modules` source filter and [`may_contain_react_code`] gate, and skip files
/// with multiple `<script>` sections (vue/astro/svelte). The latter is required for
/// correctness, not just speed — the cache on [`ContextHost`] is per file, so it
/// would replay the first script section's findings for every later section.
pub fn should_run_react_compiler(ctx: &ContextHost) -> bool {
!ctx.file_path().to_string_lossy().contains("node_modules")
&& !ctx
.file_extension()
.is_some_and(|ext| LINT_PARTIAL_LOADER_EXTENSIONS.iter().any(|e| e == &ext))
&& may_contain_react_code(ctx.semantic().nodes().program())
}
/// Port of `mayContainReactCode` in `eslint-plugin-react-hooks`'s
/// `src/shared/RunReactCompiler.ts`: only compile files with a top-level function
/// named like a component or hook, or an anonymous default-exported function.
fn may_contain_react_code(program: &Program) -> bool {
program.body.iter().any(is_top_level_react_code)
}
fn is_top_level_react_code(statement: &Statement) -> bool {
match statement {
Statement::ExportDeclaration(export) => is_react_declaration(&export.declaration),
Statement::ExportDefaultDeclaration(export) => match &export.declaration {
ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
func.r#type == FunctionType::FunctionDeclaration
&& func.id.as_ref().is_none_or(|id| is_component_or_hook_name(&id.name))
}
declaration => declaration.as_expression().is_some_and(is_function_expression),
},
_ => statement.as_declaration().is_some_and(is_react_declaration),
}
}
fn is_react_declaration(declaration: &Declaration) -> bool {
match declaration {
Declaration::FunctionDeclaration(func) => {
func.r#type == FunctionType::FunctionDeclaration
&& func.id.as_ref().is_some_and(|id| is_component_or_hook_name(&id.name))
}
Declaration::VariableDeclaration(declaration) => {
declaration.declarations.iter().any(|declarator| {
matches!(&declarator.id, BindingPattern::BindingIdentifier(id) if is_component_or_hook_name(&id.name))
&& declarator.init.as_ref().is_some_and(is_function_expression)
})
}
_ => false,
}
}
fn is_function_expression(expr: &Expression) -> bool {
matches!(
expr.without_parentheses(),
Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
)
}
/// `/^[A-Z]/` or `/^use[A-Z0-9]/`.
fn is_component_or_hook_name(name: &str) -> bool {
let bytes = name.as_bytes();
bytes.first().is_some_and(u8::is_ascii_uppercase)
|| (bytes.starts_with(b"use")
&& bytes.get(3).is_some_and(|b| b.is_ascii_uppercase() || b.is_ascii_digit()))
}
/// Shared `run_once` body for the React Compiler family of rules: report the
+22 -22
View File
@@ -375,22 +375,22 @@ promise/prefer-catch | 24802
promise/spec-only | 95297
promise/valid-params | 62606
react/button-has-type | 7019
react/capitalized-calls | 7
react/capitalized-calls | 4
react/checked-requires-onchange-or-readonly | 63058
react/display-name | 7
react/error-boundaries | 7
react/error-boundaries | 4
react/exhaustive-deps | 62606
react/exhaustive-effect-dependencies | 7
react/exhaustive-effect-dependencies | 4
react/forbid-component-props | 452
react/forward-ref-uses-ref | 16812
react/function-component-definition | 16812
react/globals | 7
react/globals | 4
react/hook-use-state | 62606
react/hooks | 7
react/hooks | 4
react/iframe-missing-sandbox | 63058
react/immutability | 7
react/incompatible-library | 7
react/invariant | 7
react/immutability | 4
react/incompatible-library | 4
react/invariant | 4
react/jsx-boolean-value | 452
react/jsx-curly-brace-presence | 459
react/jsx-filename-extension | 7
@@ -409,13 +409,13 @@ react/jsx-no-useless-fragment | 459
react/jsx-pascal-case | 452
react/jsx-props-no-spread-multi | 452
react/jsx-props-no-spreading | 6
react/memo-dependencies | 7
react/memo-dependencies | 4
react/no-array-index-key | 63058
react/no-children-prop | 14929
react/no-clone-element | 62606
react/no-danger | 7019
react/no-danger-with-children | 63058
react/no-deriving-state-in-effects | 7
react/no-deriving-state-in-effects | 4
react/no-did-mount-set-state | 6567
react/no-did-update-set-state | 62606
react/no-direct-mutation-state | 1398
@@ -438,25 +438,25 @@ react/no-will-update-set-state | 6567
react/only-export-components | 3
react/prefer-es6-class | 6745
react/prefer-function-component | 178
react/preserve-manual-memoization | 7
react/purity | 7
react/preserve-manual-memoization | 4
react/purity | 4
react/react-in-jsx-scope | 459
react/refs | 7
react/refs | 4
react/require-render-return | 2604
react/rule-suppression | 7
react/rule-suppression | 4
react/rules-of-hooks | 62606
react/self-closing-comp | 452
react/set-state-in-effect | 7
react/set-state-in-render | 7
react/set-state-in-effect | 4
react/set-state-in-render | 4
react/state-in-constructor | 13763
react/static-components | 7
react/static-components | 4
react/style-prop-object | 7019
react/syntax | 7
react/todo | 7
react/unsupported-syntax | 7
react/use-memo | 7
react/syntax | 4
react/todo | 4
react/unsupported-syntax | 4
react/use-memo | 4
react/void-dom-elements-no-children | 7019
react/void-use-memo | 7
react/void-use-memo | 4
react_perf/jsx-no-jsx-as-prop | 804
react_perf/jsx-no-new-array-as-prop | 804
react_perf/jsx-no-new-function-as-prop | 804