fix(react_compiler): honor eslint suppressions (#25394)

Honor React ESLint suppressions independently of internal compiler validation settings, matching `babel-plugin-react-compiler@1.0.0`.

Closes #25392.

AI-assisted.
This commit is contained in:
Boshen committed 2026-08-10 10:42:23 +00:00
1 parent 8e9b95f3b6
commit 8d7f9cff3f
9 files changed
+84 -159

No files matched your search

+2 -4
View File
@@ -138,10 +138,8 @@ pub struct PluginOptions {
pub output_mode: Option<CompilerOutputMode>,
/// ESLint rule names whose `eslint-disable` comments make the compiler skip the
/// 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`).
/// function they cover. `None` uses the defaults (`react-hooks/exhaustive-deps`
/// and `react-hooks/rules-of-hooks`); an empty list disables this behavior.
pub eslint_suppression_rules: Option<Vec<String>>,
/// Whether Flow suppression comments (e.g. `$FlowFixMe`) likewise make the
@@ -2883,24 +2883,23 @@ pub fn compile_program<'a>(
// Compute output mode once, up front
let output_mode = CompilerOutputMode::from_opts(&options);
// 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,
eslint_rules.as_deref(),
options.flow_suppressions,
);
// Match babel-plugin-react-compiler 1.0.0: ESLint suppressions are an explicit
// function-level opt-out, independent of the compiler's internal validations.
let suppressions = if let Some(eslint_rules) = options.eslint_suppression_rules.as_deref() {
find_program_suppressions(
&program.comments,
program.source_text,
eslint_rules,
options.flow_suppressions,
)
} else {
find_program_suppressions(
&program.comments,
program.source_text,
DEFAULT_ESLINT_SUPPRESSIONS,
options.flow_suppressions,
)
};
// Check for module-scope opt-out directive
let has_module_scope_opt_out = find_directive_disabling_memoization(
@@ -35,27 +35,27 @@ fn comment_value(comment: Comment, source_text: &str) -> &str {
}
/// Check if a comment value matches `eslint-disable-next-line <rule>` for any rule in `rule_names`.
fn matches_eslint_disable_next_line(value: &str, rule_names: &[String]) -> bool {
fn matches_eslint_disable_next_line<S: AsRef<str>>(value: &str, rule_names: &[S]) -> bool {
value
.strip_prefix("eslint-disable-next-line ")
.or_else(|| value.strip_prefix("oxlint-disable-next-line "))
.is_some_and(|rest| rule_names.iter().any(|name| rest.starts_with(name.as_str())))
.is_some_and(|rest| rule_names.iter().any(|name| rest.starts_with(name.as_ref())))
}
/// Check if a comment value matches `eslint-disable <rule>` for any rule in `rule_names`.
fn matches_eslint_disable(value: &str, rule_names: &[String]) -> bool {
fn matches_eslint_disable<S: AsRef<str>>(value: &str, rule_names: &[S]) -> bool {
value
.strip_prefix("eslint-disable ")
.or_else(|| value.strip_prefix("oxlint-disable "))
.is_some_and(|rest| rule_names.iter().any(|name| rest.starts_with(name.as_str())))
.is_some_and(|rest| rule_names.iter().any(|name| rest.starts_with(name.as_ref())))
}
/// Check if a comment value matches `eslint-enable <rule>` for any rule in `rule_names`.
fn matches_eslint_enable(value: &str, rule_names: &[String]) -> bool {
fn matches_eslint_enable<S: AsRef<str>>(value: &str, rule_names: &[S]) -> bool {
value
.strip_prefix("eslint-enable ")
.or_else(|| value.strip_prefix("oxlint-enable "))
.is_some_and(|rest| rule_names.iter().any(|name| rest.starts_with(name.as_str())))
.is_some_and(|rest| rule_names.iter().any(|name| rest.starts_with(name.as_ref())))
}
/// Check if a comment value matches a Flow suppression pattern.
@@ -87,10 +87,10 @@ fn matches_flow_suppression(value: &str) -> bool {
/// Parse eslint-disable/enable and Flow suppression comments from program comments.
/// Equivalent to findProgramSuppressions in Suppression.ts
pub fn find_program_suppressions(
pub fn find_program_suppressions<S: AsRef<str>>(
comments: &[Comment],
source_text: &str,
rule_names: Option<&[String]>,
rule_names: &[S],
flow_suppressions: bool,
) -> Vec<SuppressionRange> {
let mut suppression_ranges: Vec<SuppressionRange> = Vec::new();
@@ -98,7 +98,7 @@ pub fn find_program_suppressions(
let mut enable_comment: Option<Comment> = None;
let mut source: Option<SuppressionSource> = None;
let rule_names = rule_names.filter(|names| !names.is_empty());
let rule_names = (!rule_names.is_empty()).then_some(rule_names);
for &comment in comments {
let value = comment_value(comment, source_text);
@@ -2,56 +2,8 @@
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
---
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;
}
Diagnostics:
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Suppression: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled", labels: [LabeledSpan { label: Some("Found React rule suppression"), span: SourceSpan { offset: 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.
+18 -39
View File
@@ -182,40 +182,20 @@ fn flow_suppressions_still_bail_out_by_default() {
}
#[test]
fn eslint_suppressions_bail_out_when_either_internal_validation_is_disabled() {
fn empty_eslint_suppression_rules_disable_bailouts() {
let source = include_str!("../fixtures/default-suppression-eslint-next-line.js");
let options =
PluginOptions { eslint_suppression_rules: Some(Vec::new()), ..PluginOptions::default() };
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
for disabled_validation in ["memo dependencies", "hooks usage"] {
let mut options = PluginOptions::default();
options.environment.validate_exhaustive_memoization_dependencies = true;
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!(
!result.fatal,
"suppression must be a nonfatal bail-out when {disabled_validation} validation is disabled"
);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("[ReactCompiler] Suppression"));
}
assert!(result.changed, "an empty suppression rule list must allow compilation");
assert!(!result.fatal);
assert!(!result.diagnostics.has_errors());
}
#[test]
fn internal_validations_report_errors_hidden_by_eslint_suppressions() {
fn eslint_suppressions_take_precedence_over_internal_validations() {
let cases = [
(
"memo dependencies",
@@ -227,7 +207,6 @@ function Component({ value }) {
return <div>{doubled}</div>;
}
",
"[ReactCompiler] MemoDependencies",
),
(
"hooks usage",
@@ -241,28 +220,27 @@ function Component({ condition }) {
return <div />;
}
",
"[ReactCompiler] Hooks",
),
];
for (kind, source, expected_category) in cases {
for (kind, source) in cases {
let mut options = PluginOptions::default();
options.environment.validate_exhaustive_memoization_dependencies = true;
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(!result.changed, "{kind} validation must prevent compilation");
assert!(!result.changed, "{kind} suppression must prevent compilation");
assert_eq!(result.diagnostics.len(), 1);
assert!(
result.diagnostics[0].message.contains(expected_category),
"expected {expected_category}, got {:?}",
result.diagnostics[0].message.contains("[ReactCompiler] Suppression"),
"expected a suppression diagnostic, got {:?}",
result.diagnostics
);
}
}
#[test]
fn custom_eslint_suppressions_follow_internal_validation_gating() {
fn custom_eslint_suppressions_bail_out() {
let source = "\
function Component({ value }) {
// eslint-disable-next-line custom/react-rule
@@ -288,8 +266,9 @@ function Component({ value }) {
};
options.environment.validate_exhaustive_memoization_dependencies = true;
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());
assert!(!result.changed, "custom suppression must bail out with both validations enabled");
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("[ReactCompiler] Suppression"));
}
#[test]
@@ -298,7 +277,7 @@ fn all_errors_makes_enabled_eslint_suppressions_fatal() {
let allocator = Allocator::default();
let mut options =
PluginOptions { panic_threshold: PanicThreshold::AllErrors, ..PluginOptions::default() };
options.environment.validate_exhaustive_memoization_dependencies = false;
options.environment.validate_exhaustive_memoization_dependencies = true;
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(result.fatal, "all_errors must escalate suppression diagnostics");
+3 -2
View File
@@ -210,8 +210,9 @@ export interface ReactCompilerOptions {
/** Select client, SSR, or lint output. */
outputMode?: 'client' | 'ssr' | 'lint'
/**
* ESLint rule names whose suppressions opt a function out of compilation when
* hooks usage or exhaustive memoization dependency validation is disabled.
* ESLint rule names whose suppressions opt a function out of compilation.
* Defaults to `react-hooks/exhaustive-deps` and `react-hooks/rules-of-hooks`;
* pass an empty array to disable this behavior.
*/
eslintSuppressionRules?: Array<string>
/**
+3 -2
View File
@@ -84,8 +84,9 @@ pub struct ReactCompilerOptions {
#[napi(ts_type = "'client' | 'ssr' | 'lint'")]
pub output_mode: Option<String>,
/// ESLint rule names whose suppressions opt a function out of compilation when
/// hooks usage or exhaustive memoization dependency validation is disabled.
/// ESLint rule names whose suppressions opt a function out of compilation.
/// Defaults to `react-hooks/exhaustive-deps` and `react-hooks/rules-of-hooks`;
/// pass an empty array to disable this behavior.
pub eslint_suppression_rules: Option<Vec<String>>,
/// Treat Flow suppression comments as opt-outs.
+24 -30
View File
@@ -166,13 +166,18 @@ describe("transformSync", () => {
expect(result.code).toContain("import { CSS_VAR }");
});
it("ignores ESLint suppressions when internal validations are enabled", () => {
it("honors 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>;
"Counter.jsx",
`import { useEffect, useRef, useState } from "react";
export function Counter({ step }) {
const [count, setCount] = useState(0);
const ref = useRef(step);
useEffect(() => {
setCount((value) => value + ref.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div>{count}</div>;
}`,
{
reactCompiler: {
@@ -183,9 +188,14 @@ describe("transformSync", () => {
},
);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
expect(result.fatal).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatchObject({
severity: "Warning",
message: expect.stringContaining("[ReactCompiler] Suppression:"),
});
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).not.toContain("_c(");
});
it("honors ESLint suppressions by default", () => {
@@ -205,7 +215,7 @@ describe("transformSync", () => {
expect(result.code).not.toContain("react/compiler-runtime");
});
it("honors ESLint suppressions when internal validations are disabled", () => {
it("allows ESLint suppression bailouts to be disabled", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
@@ -215,21 +225,15 @@ describe("transformSync", () => {
}`,
{
reactCompiler: {
environment: {
validateExhaustiveMemoizationDependencies: false,
},
eslintSuppressionRules: [],
},
},
);
expect(result.fatal).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatchObject({
severity: "Warning",
message: expect.stringContaining("[ReactCompiler] Suppression:"),
});
expect(result.code).not.toBe("");
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
expect(result.code).not.toContain(": number");
expect(result.code).not.toContain("<div");
});
@@ -288,13 +292,6 @@ describe("transformSync", () => {
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`,
{
reactCompiler: {
environment: {
validateExhaustiveMemoizationDependencies: false,
},
},
},
);
expect(result.fatal).toBe(false);
@@ -319,9 +316,6 @@ describe("transformSync", () => {
{
reactCompiler: {
panicThreshold,
environment: {
validateExhaustiveMemoizationDependencies: false,
},
},
},
);
@@ -210,8 +210,9 @@ export interface ReactCompilerOptions {
/** Select client, SSR, or lint output. */
outputMode?: 'client' | 'ssr' | 'lint'
/**
* ESLint rule names whose suppressions opt a function out of compilation when
* hooks usage or exhaustive memoization dependency validation is disabled.
* ESLint rule names whose suppressions opt a function out of compilation.
* Defaults to `react-hooks/exhaustive-deps` and `react-hooks/rules-of-hooks`;
* pass an empty array to disable this behavior.
*/
eslintSuppressionRules?: Array<string>
/**