perf(react-compiler): skip passes lint does not need

Original PR: #26657 (React Compiler part)
This commit is contained in:
Yagiz Nizipli authored and Boshen committed 2026-10-29 15:21:32 +08:00
1 parent 61fe2af7f2
commit fd68928a88
22 files changed
+349 -31

No files matched your search

@@ -0,0 +1,5 @@
{
"categories": { "correctness": "off" },
"plugins": ["react"],
"rules": { "react/globals": "error" }
}
@@ -0,0 +1,7 @@
let someGlobal = false;
// Not a component or hook, so the compiler does not lint it.
export function helper() {
someGlobal = true;
return someGlobal;
}
@@ -0,0 +1,7 @@
let someGlobal = false;
// An anonymous component wrapped in `React.memo` is linted.
export const Component = React.memo(function (props) {
someGlobal = true;
return <div>{props.text}</div>;
});
@@ -0,0 +1,10 @@
import { createElement, useState } from "react";
let someGlobal = false;
// A PascalCase component that calls hooks is linted in a non-JSX file too.
export function Provider(props) {
const [value] = useState(0);
someGlobal = true;
return createElement("div", null, value, props.children);
}
@@ -0,0 +1,9 @@
import { useState } from "react";
let someGlobal = false;
// A hook is linted even without JSX.
export function useFoo() {
someGlobal = true;
return useState(0);
}
@@ -0,0 +1,48 @@
# Exit code
1
# 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;
: ^^^^^|^^^^
: `-- `someGlobal` cannot be reassigned
6 | 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
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/provider.ts:8:3]
7 | const [value] = useState(0);
8 | someGlobal = true;
: ^^^^^|^^^^
: `-- `someGlobal` cannot be reassigned
9 | return createElement("div", null, value, props.children);
`----
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
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/use-foo.js:7:3]
6 | export function useFoo() {
7 | someGlobal = true;
: ^^^^^|^^^^
: `-- `someGlobal` cannot be reassigned
8 | return useState(0);
`----
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
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 3 errors.
Finished in Xms on 4 files with 1 rules using X threads.
```
# stderr
```
```
@@ -0,0 +1,5 @@
{
"categories": { "correctness": "off" },
"plugins": ["react"],
"rules": { "react/todo": "error" }
}
@@ -0,0 +1,9 @@
export function Foo() {
function foo() {
return bar();
}
function bar() {
return 42;
}
return <div fn1={foo} fn2={bar} />;
}
@@ -0,0 +1,22 @@
# Exit code
1
# stdout
```
x react(todo): [PruneHoistedContexts] Rewrite hoisted function references
,-[files/index.jsx:3:12]
2 | function foo() {
3 | return bar();
: ^^^
4 | }
`----
help: Rewrite the highlighted code using syntax supported by React Compiler
note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/unsupported-syntax
Found 0 warnings and 1 error.
Finished in Xms on 1 file with 1 rules using X threads.
```
# stderr
```
```
+6 -2
View File
@@ -347,14 +347,18 @@ impl Linter {
let mut timing_recorder = TIMINGS.then(|| RuleTimingRecorder::with_capacity(rules.len()));
// The shared React Compiler run takes its environment additions from the
// `react/capitalized-calls` rule's resolved options.
let react_compiler_env_options = rules
// `react/capitalized-calls` rule's resolved options, and runs the passes
// that only report `Todo` and `Invariant` when their rules are enabled.
let mut react_compiler_env_options = rules
.iter()
.find_map(|(rule, _)| match rule {
RuleEnum::ReactCapitalizedCalls(rule) => Some(rule.react_compiler_env_options()),
_ => None,
})
.unwrap_or_default();
react_compiler_env_options.report_todo_and_invariant = rules
.iter()
.any(|(rule, _)| matches!(rule, RuleEnum::ReactTodo(_) | RuleEnum::ReactInvariant(_)));
let mut ctx_host = Rc::new(
ContextHost::new(path, context_sub_hosts, allocator, self.options, config)
@@ -92,6 +92,7 @@ impl CapitalizedCalls {
ReactCompilerEnvOptions {
capitalized_calls_allow: self.0.allow.clone(),
capitalized_calls_allow_pattern: self.0.allow_pattern.clone(),
..ReactCompilerEnvOptions::default()
}
}
}
@@ -14,15 +14,18 @@ use crate::{
/// Environment additions to the shared React Compiler run that come from rule
/// configuration, extracted once per resolved config in `Linter::run` (from the
/// `react/capitalized-calls` rule's options). Config-derived rather than
/// per-run rule state, so the single shared run stays valid for any
/// combination of enabled rules.
/// `react/capitalized-calls` rule's options and whether `react/todo` or
/// `react/invariant` is enabled). Config-derived rather than per-run rule state,
/// so the single shared run stays valid for any combination of enabled rules.
#[derive(Debug, Default, Clone)]
pub struct ReactCompilerEnvOptions {
/// Extra allowed names for `validateNoCapitalizedCalls`.
pub capitalized_calls_allow: Vec<String>,
/// Allowed-name pattern for `validateNoCapitalizedCalls`.
pub capitalized_calls_allow_pattern: Option<Regex>,
/// Whether `react/todo` or `react/invariant` is enabled. Otherwise lint skips
/// the compiler passes that only report those categories.
pub report_todo_and_invariant: bool,
}
/// The compiler options the React Compiler family of rules lints with — `lint`
@@ -68,6 +71,7 @@ fn react_compiler_plugin_options(env_options: &ReactCompilerEnvOptions) -> Plugi
validate_exhaustive_effect_dependencies: ExhaustiveEffectDepsMode::All,
..EnvironmentConfig::default()
},
lint_skip_reactive_scope_passes: !env_options.report_todo_and_invariant,
..PluginOptions::default()
}
}
@@ -0,0 +1,15 @@
// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses
import {useEffect, useRef, useState} from 'react';
// Without useMemo/useCallback, lint skips the reactive-scope passes but still
// infers reactive places for the effect: props are required dependencies,
// while the state setter and the ref are stable and may be omitted.
function Component({value, other}) {
const [count, setCount] = useState(0);
const ref = useRef(null);
useEffect(() => {
setCount(value);
ref.current = other;
}, []);
return <div>{count}</div>;
}
@@ -0,0 +1,12 @@
// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses
import * as React from 'react';
// The effect hook is only recognisable by its type: the callee is a local
// destructured from the React namespace, not a `useEffect` load or property.
function Component({value}) {
const {useEffect} = React;
useEffect(() => {
console.log(value);
}, []);
return <div>{value}</div>;
}
@@ -0,0 +1,14 @@
// @outputMode:"lint" @panicThreshold:"none"
import {Stringify} from 'shared-runtime';
// Lint reports the Todo that a pass after validation records for the hoisted
// function reference.
function Foo() {
function foo() {
return bar();
}
function bar() {
return 42;
}
return <Stringify fn1={foo} fn2={bar} />;
}
+6
View File
@@ -154,6 +154,11 @@ pub struct PluginOptions {
/// on top of the built-in `"use no memo"` / `"use no forget"`.
pub custom_opt_out_directives: Option<Vec<String>>,
/// In lint output mode, skip the reactive-scope passes for functions without
/// `useMemo`/`useCallback`. Those passes only report `Todo` and `Invariant`
/// diagnostics, so set this only when those diagnostics are not needed.
pub lint_skip_reactive_scope_passes: bool,
/// Compiler environment: feature flags, validation toggles, and custom hook/type
/// definitions for the compilation passes. See [`EnvironmentConfig`].
pub environment: EnvironmentConfig,
@@ -175,6 +180,7 @@ impl Default for PluginOptions {
flow_suppressions: true,
ignore_use_no_forget: false,
custom_opt_out_directives: None,
lint_skip_reactive_scope_passes: false,
environment: EnvironmentConfig::default(),
}
}
@@ -16,8 +16,9 @@ use crate::react_compiler_hir::environment::Environment;
use crate::react_compiler_hir::environment::OutputMode;
use crate::react_compiler_hir::environment_config::{EnvironmentConfig, ExhaustiveEffectDepsMode};
use crate::react_compiler_hir::{
ReactFunctionType, assert_consistent_identifiers, assert_terminal_preds_exist,
assert_terminal_successors_exist, assert_valid_block_nesting,
HirFunction, InstructionValue, ReactFunctionType, assert_consistent_identifiers,
assert_terminal_preds_exist, assert_terminal_successors_exist, assert_valid_block_nesting,
is_use_effect_hook_type, is_use_insertion_effect_hook_type, is_use_layout_effect_hook_type,
};
use crate::react_compiler_inference::align_method_call_scopes;
use crate::react_compiler_inference::align_object_method_scopes;
@@ -231,6 +232,31 @@ pub fn compile_fn<'a, const EMIT: bool>(
validate_no_freezing_known_mutable_functions(&hir, &mut env);
}
// Lint does not emit a rewrite. Without useMemo/useCallback there is no
// manual memoization to validate, so callers that do not need `Todo` and
// `Invariant` diagnostics can skip the reactive-scope passes below, which
// only report those. Reactive places are still inferred when an effect hook
// call needs its dependencies checked.
if !EMIT
&& env.output_mode == OutputMode::Lint
&& context.opts.lint_skip_reactive_scope_passes
&& !hir_has_start_memoize(&hir)
{
if env.enable_validations() {
if env.config.validate_static_components {
let errors = validate_static_components(&hir, &env.functions);
log_errors_as_events(&errors, context);
}
if env.config.validate_exhaustive_effect_dependencies != ExhaustiveEffectDepsMode::Off
&& calls_effect_hook(&hir, &env)
{
infer_reactive_places(&mut hir, &mut env)?;
validate_exhaustive_dependencies(&mut hir, &mut env)?;
}
}
return finish_lint_fn(&mut env, context);
}
infer_reactive_places(&mut hir, &mut env)?;
if env.enable_validations()
@@ -384,3 +410,45 @@ pub fn compile_fn<'a, const EMIT: bool>(
fn log_errors_as_events(errors: &Diagnostics, context: &mut ProgramContext) {
context.diagnostics.extend(errors.iter().cloned());
}
fn hir_has_start_memoize(hir: &HirFunction<'_>) -> bool {
hir.instructions
.iter()
.any(|instruction| matches!(instruction.value, InstructionValue::StartMemoize { .. }))
}
/// Whether `hir` calls an effect hook, by type — the calls
/// [`validate_exhaustive_dependencies`] checks. Nested functions are not
/// scanned because the validator does not check effect calls inside them.
fn calls_effect_hook(hir: &HirFunction<'_>, env: &Environment<'_>) -> bool {
hir.instructions.iter().any(|instruction| {
let callee = match &instruction.value {
InstructionValue::CallExpression { callee, .. } => callee,
InstructionValue::MethodCall { property, .. } => property,
_ => return false,
};
let ty = &env.types[env.identifiers[callee.identifier].type_];
is_use_effect_hook_type(ty)
|| is_use_layout_effect_hook_type(ty)
|| is_use_insertion_effect_hook_type(ty)
})
}
fn finish_lint_fn<'a>(
env: &mut Environment<'a>,
context: &mut ProgramContext<'a>,
) -> Result<Option<CodegenFunction<'a>>, Diagnostics> {
if env.config.throw_unknown_exception_testonly {
return Err(Diagnostics::from(diagnostics::invariant_unexpected_error()));
}
if env.has_errors() {
if let Some(uid_names) = env.take_uid_known_names() {
context.merge_uid_known_names(&uid_names);
}
return Err(env.take_errors());
}
if let Some(uid_names) = env.take_uid_known_names() {
context.merge_uid_known_names(&uid_names);
}
Ok(None)
}
@@ -2065,24 +2065,22 @@ fn find_functions_to_compile<'b, 'ast>(
// -----------------------------------------------------------------------
/// Cheap, sound pre-check for [`find_functions_to_compile`]: `false` means the
/// discovery walk cannot queue anything, so the compile is a no-op. Built from
/// data `Semantic` already computed instead of walking the AST, and delegating
/// all judgment to discovery's own helpers:
/// discovery walk cannot queue anything, so the compile is a no-op.
///
/// - every discoverable function creates a function scope, so the node behind
/// each function scope is run through [`try_make_compile_source`] with the
/// name discovery would infer — own id for declarations, the directly
/// enclosing `const Foo = ...` declarator for expressions/arrows, the only
/// name sources discovery uses outside forwardRef/memo. This covers the
/// named and directive-opt-in selection paths, nested functions included;
/// - the forwardRef/memo path needs an identifier named `memo`, `forwardRef`,
/// or `React` in callee position of a call [`get_callee_name_if_react_api`]
/// recognizes, so checking the reference shapes of those three names —
/// bindings and unresolved globals — covers wrapped anonymous functions.
/// `precise` (compile/emit) runs each function through [`try_make_compile_source`]
/// so files like `react.development.js` — hook *implementations*, not callers —
/// do not pay a discovery walk that queues nothing. Lint uses a name-only
/// over-approximation: a PascalCase / `useX` name or a non-empty directive list
/// is enough; the JSX/hook body scan is left to discovery.
///
/// The forwardRef/memo path needs an identifier named `memo`, `forwardRef`,
/// or `React` in callee position of a call [`get_callee_name_if_react_api`]
/// recognizes, so checking the reference shapes of those three names —
/// bindings and unresolved globals — covers wrapped anonymous functions.
///
/// Over-approximation is fine (the walk then finds an empty queue); a missed
/// witness is not, since a skipped file is never compiled.
fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> bool {
fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions, precise: bool) -> bool {
// 'all' mode compiles every top-level function; always walk.
if opts.compilation_mode == CompilationMode::All {
return true;
@@ -2104,9 +2102,7 @@ fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> b
}
}
// Named components/hooks and directive opt-ins: run the node behind every
// function scope through discovery's candidate constructor. parent_callee
// is None — wrapper-selected functions are witnessed by reference shapes.
// Named components/hooks and directive opt-ins.
let mut discarded = FxHashSet::default();
for scope_id in scoping.scope_descendants_from_root() {
if !scoping.scope_flags(scope_id).is_function() {
@@ -2124,7 +2120,6 @@ fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> b
(declarator_name_for(nodes, node.id()), OriginalFnKind::FunctionExpression)
}
};
// A nameless function without directives can never classify.
if name.is_none()
&& func.body.as_ref().is_none_or(|body| body.directives.is_empty())
{
@@ -2143,8 +2138,21 @@ fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> b
}
_ => continue,
};
if try_make_compile_source(fn_node, name, original_kind, None, opts, &mut discarded)
.is_some()
if precise {
if try_make_compile_source(fn_node, name, original_kind, None, opts, &mut discarded)
.is_some()
{
return true;
}
} else if name.is_some_and(|n| is_hook_name(n) || is_component_name(n))
|| match fn_node {
FunctionNode::Function(func) => {
func.body.as_ref().is_some_and(|body| !body.directives.is_empty())
}
FunctionNode::Arrow(arrow) => {
arrow.get_function_body().is_some_and(|body| !body.directives.is_empty())
}
}
{
return true;
}
@@ -3143,7 +3151,7 @@ pub fn compile_program<'a, const EMIT: bool>(
// Find all functions to compile. An empty queue means no work, so return
// before all the setup below, which only compilation needs. The pre-check
// decides emptiness from semantic data without walking the AST.
if !may_have_functions_to_compile(semantic, &options) {
if !may_have_functions_to_compile(semantic, &options, EMIT) {
return CompileResult::Success { output: None, diagnostics: Diagnostics::new() };
}
let queue = find_functions_to_compile(program, &options);
@@ -3193,10 +3201,10 @@ pub fn compile_program<'a, const EMIT: bool>(
let ast = AstBuilder::new(allocator);
let scope = ScopeResolver::new(semantic, allocator);
// Initialize known referenced names from scope bindings for UID collision detection
context.init_from_scope(&scope);
if EMIT {
// Seed UID collision detection with every binding in the file. Lint
// does not generate identifiers, so skip the walk.
context.init_from_scope(&scope);
// Pre-register instrumentation imports to get stable local names.
// These are needed before compilation so codegen can use the correct names.
let (instrument_fn_name, instrument_gating_name) = if let Some(ref instrument_config) =
@@ -221,6 +221,7 @@ fn parse_pragma(source: &str) -> (SourceType, PluginOptions) {
}
}
"outputMode" => options.output_mode = value.and_then(|v| unquote(v).parse().ok()),
"lintSkipReactiveScopePasses" => options.lint_skip_reactive_scope_passes = true,
// Fixed test default (upstream `testComplexPluginOptionDefaults`).
"gating" => {
options.gating = Some(GatingConfig {
@@ -0,0 +1,22 @@
---
source: crates/oxc_react_compiler/tests/snapshot.rs
input_file: crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-deps-without-manual-memo.js
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "Found missing effect dependencies", labels: [LabeledSpan { label: Some("This dependency list is missing values used by the callback"), span: Span { start: 570, end: 572 }, primary: true }, LabeledSpan { label: Some("Missing dependency `other`"), span: Span { start: 558, end: 563 }, primary: false }, LabeledSpan { label: Some("Missing dependency `value`"), span: Span { start: 532, end: 537 }, primary: false }], help: Some("Missing dependencies can cause an effect to fire less often than it should"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("EffectExhaustiveDependencies") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps") } }
// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses
import { useEffect, useRef, useState } from "react";
// Without useMemo/useCallback, lint skips the reactive-scope passes but still
// infers reactive places for the effect: props are required dependencies,
// while the state setter and the ref are stable and may be omitted.
function Component({ value, other }) {
const [count, setCount] = useState(0);
const ref = useRef(null);
useEffect(() => {
setCount(value);
ref.current = other;
}, []);
return <div>{count}</div>;
}
@@ -0,0 +1,20 @@
---
source: crates/oxc_react_compiler/tests/snapshot.rs
input_file: crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-hook-destructured-from-react.js
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "Hooks may not be referenced as normal values; they must be called", labels: [LabeledSpan { label: Some("This Hook is used as a value"), span: Span { start: 365, end: 374 }, primary: true }], help: Some("Call the Hook directly instead of passing or storing it as a value"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("Hooks") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/rules-of-hooks") } }
OxcDiagnostic { inner: OxcDiagnosticInner { message: "Found missing effect dependencies", labels: [LabeledSpan { label: Some("This dependency list is missing values used by the callback"), span: Span { start: 412, end: 414 }, primary: true }, LabeledSpan { label: Some("Missing dependency `value`"), span: Span { start: 399, end: 404 }, primary: false }], help: Some("Missing dependencies can cause an effect to fire less often than it should"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("EffectExhaustiveDependencies") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps") } }
// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses
import * as React from "react";
// The effect hook is only recognisable by its type: the callee is a local
// destructured from the React namespace, not a `useEffect` load or property.
function Component({ value }) {
const { useEffect } = React;
useEffect(() => {
console.log(value);
}, []);
return <div>{value}</div>;
}
@@ -0,0 +1,21 @@
---
source: crates/oxc_react_compiler/tests/snapshot.rs
input_file: crates/oxc_react_compiler/fixtures/lint-reports-todo-from-reactive-scope-passes.js
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[PruneHoistedContexts] Rewrite hoisted function references", labels: [LabeledSpan { label: None, span: Span { start: 236, end: 239 }, primary: true }], help: Some("Rewrite the highlighted code using syntax supported by React Compiler"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Advice, code: OxcCode { scope: Some("react-compiler"), number: Some("Todo") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/unsupported-syntax") } }
// @outputMode:"lint" @panicThreshold:"none"
import { Stringify } from "shared-runtime";
// Lint reports the Todo that a pass after validation records for the hoisted
// function reference.
function Foo() {
function foo() {
return bar();
}
function bar() {
return 42;
}
return <Stringify fn1={foo} fn2={bar} />;
}