fix(react_compiler): match eslint suppression gating (#25068)

Matches React Compiler ESLint suppression gating for internal validation options.

AI-assisted.
This commit is contained in:
Boshen committed 2026-07-29 12:41:00 +00:00
1 parent 0dc16ba3a0
commit d349e4a449
12 files changed
+277 -42

No files matched your search

+3 -2
View File
@@ -138,8 +138,9 @@ pub struct PluginOptions {
pub output_mode: Option<CompilerOutputMode>,
/// ESLint rule names whose `eslint-disable` comments make the compiler skip the
/// function they cover, so it doesn't optimize code the author has already
/// flagged. `None` uses the defaults (`react-hooks/exhaustive-deps` and
/// function they cover. These suppressions are ignored when both hooks usage
/// and exhaustive memoization dependency validation are enabled. Otherwise,
/// `None` uses the defaults (`react-hooks/exhaustive-deps` and
/// `react-hooks/rules-of-hooks`).
pub eslint_suppression_rules: Option<Vec<String>>,
@@ -2853,16 +2853,22 @@ pub fn compile_program<'a>(
// Compute output mode once, up front
let output_mode = CompilerOutputMode::from_opts(&options);
let eslint_rules = options
.eslint_suppression_rules
.clone()
.unwrap_or_else(|| DEFAULT_ESLINT_SUPPRESSIONS.iter().map(|s| s.to_string()).collect());
// The compiler's own validations cover the safety concerns represented by the
// React Hooks ESLint rules. Match Babel by only consulting ESLint suppressions
// when either validation is disabled.
let eslint_rules = (!options.environment.validate_exhaustive_memoization_dependencies
|| !options.environment.validate_hooks_usage)
.then(|| {
options.eslint_suppression_rules.clone().unwrap_or_else(|| {
DEFAULT_ESLINT_SUPPRESSIONS.iter().map(|s| s.to_string()).collect()
})
});
// Find program-level suppressions from comments
let suppressions = find_program_suppressions(
&program.comments,
program.source_text,
Some(&eslint_rules),
eslint_rules.as_deref(),
options.flow_suppressions,
);
@@ -2,8 +2,19 @@
source: crates/oxc_react_compiler/tests/snapshot.rs
input_file: crates/oxc_react_compiler/fixtures/default-suppression-eslint-block-range.js
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Suppression: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled", labels: One([LabeledSpan { label: Some("Found React rule suppression"), span: SourceSpan { offset: SourceOffset(0), length: 47 }, primary: false }]), help: Some("React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. Found suppression `eslint-disable react-hooks/rules-of-hooks`"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
No changes.
import { c as _c } from "react/compiler-runtime";
/* eslint-disable react-hooks/rules-of-hooks */
function Component(t0) {
const $ = _c(2);
const { value } = t0;
const doubled = value * 2;
let t1;
if ($[0] !== doubled) {
t1 = <div>{doubled}</div>;
$[0] = doubled;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
@@ -2,8 +2,18 @@
source: crates/oxc_react_compiler/tests/snapshot.rs
input_file: crates/oxc_react_compiler/fixtures/default-suppression-eslint-next-line.js
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Suppression: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled", labels: One([LabeledSpan { label: Some("Found React rule suppression"), span: SourceSpan { offset: SourceOffset(34), length: 55 }, primary: false }]), help: Some("React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. Found suppression `eslint-disable-next-line react-hooks/exhaustive-deps`"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
No changes.
import { c as _c } from "react/compiler-runtime";
function Component(t0) {
const $ = _c(2);
const { value } = t0;
const doubled = value * 2;
let t1;
if ($[0] !== doubled) {
t1 = <div>{doubled}</div>;
$[0] = doubled;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
@@ -2,8 +2,56 @@
source: crates/oxc_react_compiler/tests/snapshot.rs
input_file: crates/oxc_react_compiler/fixtures/exhaustive-deps/compile-files-with-exhaustive-deps-violation-in-effects.js
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Suppression: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled", labels: One([LabeledSpan { label: Some("Found React rule suppression"), span: SourceSpan { offset: SourceOffset(210), length: 55 }, primary: false }]), help: Some("React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. Found suppression `eslint-disable-next-line react-hooks/exhaustive-deps`"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
No changes.
import { c as _c } from "react/compiler-runtime";
// @validateExhaustiveMemoizationDependencies
import { useMemo } from "react";
import { ValidateMemoization } from "shared-runtime";
function Component(t0) {
const $ = _c(10);
const { x } = t0;
let t1;
if ($[0] !== x) {
t1 = () => {
console.log(x);
};
$[0] = x;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = [];
$[2] = t2;
} else {
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== x) {
t3 = [x];
$[3] = x;
$[4] = t3;
} else {
t3 = $[4];
}
const memo = t3;
let t4;
if ($[5] !== x) {
t4 = [x];
$[5] = x;
$[6] = t4;
} else {
t4 = $[6];
}
let t5;
if ($[7] !== memo || $[8] !== t4) {
t5 = <ValidateMemoization inputs={t4} output={memo} />;
$[7] = memo;
$[8] = t4;
$[9] = t5;
} else {
t5 = $[9];
}
return t5;
}
@@ -4,6 +4,6 @@ input_file: crates/oxc_react_compiler/fixtures/exhaustive-deps/error.sketchy-cod
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Suppression: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled", labels: One([LabeledSpan { label: Some("Found React rule suppression"), span: SourceSpan { offset: SourceOffset(203), length: 55 }, primary: false }]), help: Some("React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. Found suppression `eslint-disable-next-line react-hooks/exhaustive-deps`"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] MemoDependencies: Found missing memoization dependencies", labels: One([LabeledSpan { label: Some("Missing dependency `item`"), span: SourceSpan { offset: SourceOffset(186), length: 4 }, primary: false }]), help: Some("Missing dependencies can cause a value to update less often than it should, resulting in stale UI"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
No changes.
@@ -4,7 +4,7 @@ input_file: crates/oxc_react_compiler/fixtures/use-no-forget-multiple-with-eslin
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Suppression: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled", labels: One([LabeledSpan { label: Some("Found React rule suppression"), span: SourceSpan { offset: SourceOffset(176), length: 54 }, primary: false }]), help: Some("React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. Found suppression `eslint-disable-next-line react-hooks/rules-of-hooks`"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Refs: Cannot access refs during render", labels: One([LabeledSpan { label: Some("Cannot update ref during render"), span: SourceSpan { offset: SourceOffset(233), length: 11 }, primary: false }]), help: Some("React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef)"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
import { useRef } from "react";
const useControllableState = (options) => {};
@@ -4,6 +4,6 @@ input_file: crates/oxc_react_compiler/fixtures/use-no-forget-with-eslint-suppres
---
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Suppression: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled", labels: One([LabeledSpan { label: Some("Found React rule suppression"), span: SourceSpan { offset: SourceOffset(103), length: 54 }, primary: false }]), help: Some("React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. Found suppression `eslint-disable-next-line react-hooks/rules-of-hooks`"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Refs: Cannot access refs during render", labels: One([LabeledSpan { label: Some("Cannot update ref during render"), span: SourceSpan { offset: SourceOffset(160), length: 11 }, primary: false }]), help: Some("React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef)"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
No changes.
+135 -15
View File
@@ -8,7 +8,7 @@ use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use oxc_react_compiler::{PluginOptions, compile};
use oxc_react_compiler::{PanicThreshold, PluginOptions, compile};
fn options() -> PluginOptions {
PluginOptions::default()
@@ -69,42 +69,162 @@ fn skips_non_react_code() {
}
#[test]
fn default_suppressions_bail_out() {
fn default_eslint_suppressions_do_not_bail_out() {
let fixtures = [
(
"eslint-disable-next-line",
include_str!("../fixtures/default-suppression-eslint-next-line.js"),
1,
),
(
"eslint-disable block range",
include_str!("../fixtures/default-suppression-eslint-block-range.js"),
1,
),
("Flow", include_str!("../fixtures/default-suppression-flow.js"), 1),
];
// Babel 1.0.0 currently treats a block disable as open-ended. The block fixture
// therefore covers the disable/enable syntax without asserting post-enable behavior.
for (kind, source, expected_errors) in fixtures {
for (kind, source) in fixtures {
let allocator = Allocator::default();
let (_program, result) =
transform_source(source, SourceType::tsx(), &allocator, PluginOptions::default());
assert!(!result.changed, "{kind} suppression must prevent compilation");
assert!(result.changed, "{kind} must not prevent compilation");
assert!(
result.diagnostics.has_errors(),
"{kind} suppression must report the compiler bail-out: {:?}",
!result.diagnostics.has_errors(),
"{kind} produced unexpected diagnostics: {:?}",
result.diagnostics
);
assert_eq!(
result.diagnostics.len(),
expected_errors,
"{kind} suppression should affect the same functions as the Babel plugin"
}
}
#[test]
fn flow_suppressions_still_bail_out_by_default() {
let source = include_str!("../fixtures/default-suppression-flow.js");
let allocator = Allocator::default();
let (_program, result) =
transform_source(source, SourceType::tsx(), &allocator, PluginOptions::default());
assert!(!result.changed, "Flow suppression must prevent compilation");
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("[ReactCompiler] Suppression"));
}
#[test]
fn eslint_suppressions_bail_out_when_either_internal_validation_is_disabled() {
let source = include_str!("../fixtures/default-suppression-eslint-next-line.js");
for disabled_validation in ["memo dependencies", "hooks usage"] {
let mut options = PluginOptions::default();
match disabled_validation {
"memo dependencies" => {
options.environment.validate_exhaustive_memoization_dependencies = false;
}
"hooks usage" => {
options.environment.validate_hooks_usage = false;
}
_ => unreachable!(),
}
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(
!result.changed,
"suppression must prevent compilation when {disabled_validation} validation is disabled"
);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("[ReactCompiler] Suppression"));
}
}
#[test]
fn internal_validations_report_errors_hidden_by_eslint_suppressions() {
let cases = [
(
"memo dependencies",
"\
import { useMemo } from 'react';
function Component({ value }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = useMemo(() => value * 2, []);
return <div>{doubled}</div>;
}
",
"[ReactCompiler] MemoDependencies",
),
(
"hooks usage",
"\
import { useState } from 'react';
function Component({ condition }) {
if (condition) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useState(0);
}
return <div />;
}
",
"[ReactCompiler] Hooks",
),
];
for (kind, source, expected_category) in cases {
let allocator = Allocator::default();
let (_program, result) =
transform_source(source, SourceType::tsx(), &allocator, PluginOptions::default());
assert!(!result.changed, "{kind} validation must prevent compilation");
assert_eq!(result.diagnostics.len(), 1);
assert!(
result.diagnostics[0].message.contains(expected_category),
"expected {expected_category}, got {:?}",
result.diagnostics
);
}
}
#[test]
fn custom_eslint_suppressions_follow_internal_validation_gating() {
let source = "\
function Component({ value }) {
// eslint-disable-next-line custom/react-rule
const doubled = value * 2;
return <div>{doubled}</div>;
}
";
let allocator = Allocator::default();
let options = PluginOptions {
eslint_suppression_rules: Some(vec!["custom/react-rule".to_string()]),
..PluginOptions::default()
};
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(result.changed, "custom suppression must be ignored with both validations enabled");
assert!(!result.diagnostics.has_errors());
let allocator = Allocator::default();
let mut options = PluginOptions {
eslint_suppression_rules: Some(vec!["custom/react-rule".to_string()]),
..PluginOptions::default()
};
options.environment.validate_exhaustive_memoization_dependencies = false;
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(!result.changed, "custom suppression must bail out when validation is disabled");
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("[ReactCompiler] Suppression"));
}
#[test]
fn all_errors_makes_enabled_eslint_suppressions_fatal() {
let source = include_str!("../fixtures/default-suppression-eslint-next-line.js");
let allocator = Allocator::default();
let mut options =
PluginOptions { panic_threshold: PanicThreshold::AllErrors, ..PluginOptions::default() };
options.environment.validate_exhaustive_memoization_dependencies = false;
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(!result.changed, "a fatal result must not produce a rewrite");
assert!(result.diagnostics.has_errors());
}
/// TypeScript-only constructs (`declare global`, `import =`, `export =`,
/// overload signatures, `#field in obj`) round-trip without panicking while the
/// component still compiles.
+4 -1
View File
@@ -154,7 +154,10 @@ export interface TransformOptions {
noEmit?: boolean
/** Select client, SSR, or lint output. */
outputMode?: 'client' | 'ssr' | 'lint'
/** ESLint rule names whose suppressions opt a function out of compilation. */
/**
* ESLint rule names whose suppressions opt a function out of compilation when
* hooks usage or exhaustive memoization dependency validation is disabled.
*/
eslintSuppressionRules?: Array<string>
/**
* Treat Flow suppression comments as opt-outs.
+2 -1
View File
@@ -65,7 +65,8 @@ pub struct TransformOptions {
#[napi(ts_type = "'client' | 'ssr' | 'lint'")]
pub output_mode: Option<String>,
/// ESLint rule names whose suppressions opt a function out of compilation.
/// ESLint rule names whose suppressions opt a function out of compilation when
/// hooks usage or exhaustive memoization dependency validation is disabled.
pub eslint_suppression_rules: Option<Vec<String>>,
/// Treat Flow suppression comments as opt-outs.
@@ -145,6 +145,41 @@ describe("transformSync", () => {
expect(result.code).toContain("[CSS_VAR]");
expect(result.code).toContain("import { CSS_VAR }");
});
it("ignores ESLint suppressions when internal validations are enabled", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled: number = value * 2;
return <div>{doubled}</div>;
}`,
);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
});
it("honors ESLint suppressions when internal validations are disabled", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled: number = value * 2;
return <div>{doubled}</div>;
}`,
{
environment: {
validateExhaustiveMemoizationDependencies: false,
},
},
);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain("[ReactCompiler] Suppression:");
expect(result.code).toBe("");
});
});
describe("transform", () => {