mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 06:39:17 +00:00
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:
9 files changed
+84
-159
No files matched your search
@@ -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);
|
||||
|
||||
+5
-53
@@ -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.
|
||||
@@ -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");
|
||||
|
||||
Vendored
+3
-2
@@ -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>
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user