mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 14:49:36 +00:00
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:
12 files changed
+277
-42
No files matched your search
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+16
-5
@@ -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;
|
||||
}
|
||||
+15
-5
@@ -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;
|
||||
}
|
||||
+53
-5
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
+1
-1
@@ -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) => {};
|
||||
|
||||
+1
-1
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Vendored
+4
-1
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user