mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 06:39:17 +00:00
fix(react_compiler): preserve nonfatal diagnostics (#25066)
Preserves nonfatal React Compiler diagnostics and covers warning bailouts through the N-API binding. AI-assisted.
This commit is contained in:
20 files changed
+356
-85
No files matched your search
@@ -20,7 +20,7 @@ use oxc_parser::Parser;
|
||||
use oxc_semantic::SemanticBuilder;
|
||||
use oxc_span::SourceType;
|
||||
|
||||
use oxc_react_compiler::{PluginOptions, compile};
|
||||
use oxc_react_compiler::{CompileResult, PluginOptions, compile};
|
||||
|
||||
/// Compile a React component with the Rust React Compiler and print the result.
|
||||
fn main() {
|
||||
@@ -39,14 +39,20 @@ fn main() {
|
||||
let allocator = Allocator::default();
|
||||
let mut program = Parser::new(&allocator, &source_text, source_type).parse().program;
|
||||
|
||||
let (output, diagnostics) = {
|
||||
let result = {
|
||||
let semantic = SemanticBuilder::new().with_build_nodes(true).build(&program).semantic;
|
||||
compile(&program, &semantic, &allocator, PluginOptions::default())
|
||||
};
|
||||
let changed = output.is_some();
|
||||
if let Some(output) = output {
|
||||
output.transform(&mut program);
|
||||
}
|
||||
let (changed, diagnostics, fatal) = match result {
|
||||
CompileResult::Success { output, diagnostics } => {
|
||||
let changed = output.is_some();
|
||||
if let Some(output) = output {
|
||||
output.transform(&mut program);
|
||||
}
|
||||
(changed, diagnostics, false)
|
||||
}
|
||||
CompileResult::Fatal { diagnostics } => (false, diagnostics, true),
|
||||
};
|
||||
|
||||
if !diagnostics.is_empty() {
|
||||
println!("Diagnostics:\n");
|
||||
@@ -56,7 +62,9 @@ fn main() {
|
||||
println!();
|
||||
}
|
||||
|
||||
if changed {
|
||||
if fatal {
|
||||
println!("Compilation aborted.");
|
||||
} else if changed {
|
||||
let code = Codegen::new().build(&program).code;
|
||||
println!("Compiled:\n");
|
||||
println!("{code}");
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
use oxc_diagnostics::{OxcDiagnostic, Severity};
|
||||
use oxc_span::Span;
|
||||
|
||||
use crate::options::PanicThreshold;
|
||||
|
||||
/// Error categories matching the TS `ErrorCategory` enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorCategory {
|
||||
@@ -122,6 +124,18 @@ pub fn has_critical_errors(diagnostics: &[OxcDiagnostic]) -> bool {
|
||||
.any(|d| d.severity == Severity::Error && !ErrorCategory::PreserveManualMemo.matches(d))
|
||||
}
|
||||
|
||||
/// Whether diagnostics should abort compilation for the configured panic threshold.
|
||||
///
|
||||
/// Config errors are always fatal, matching the upstream compiler.
|
||||
pub fn should_panic(diagnostics: &[OxcDiagnostic], panic_threshold: PanicThreshold) -> bool {
|
||||
diagnostics.iter().any(|d| ErrorCategory::Config.matches(d))
|
||||
|| match panic_threshold {
|
||||
PanicThreshold::AllErrors => true,
|
||||
PanicThreshold::CriticalErrors => has_critical_errors(diagnostics),
|
||||
PanicThreshold::None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned copy of a diagnostic for the log accumulator, labelling the enclosing
|
||||
/// function (`fn_span`) when the diagnostic carries no location of its own.
|
||||
#[cold]
|
||||
|
||||
@@ -15,12 +15,12 @@ mod react_compiler_typeinference;
|
||||
mod react_compiler_utils;
|
||||
mod react_compiler_validation;
|
||||
|
||||
use crate::react_compiler::entrypoint::compile_result::CompileResult;
|
||||
use crate::react_compiler::entrypoint::imports::{
|
||||
get_react_compiler_runtime_module, has_memo_cache_function_import, validate_restricted_imports,
|
||||
};
|
||||
use crate::react_compiler::entrypoint::program::compile_program;
|
||||
|
||||
pub use crate::react_compiler::entrypoint::compile_result::CompileResult;
|
||||
pub use crate::react_compiler::entrypoint::program::CompileOutput;
|
||||
|
||||
// Re-exported so integrations needn't depend on the upstream `react_compiler` crates.
|
||||
@@ -46,15 +46,17 @@ use oxc_semantic::Semantic;
|
||||
pub struct LintResult {
|
||||
/// Errors and warnings produced by the compile.
|
||||
pub diagnostics: Diagnostics,
|
||||
/// Whether compilation was aborted according to `panic_threshold`.
|
||||
pub fatal: bool,
|
||||
}
|
||||
|
||||
/// Run the React Compiler on a pre-parsed program.
|
||||
///
|
||||
/// Returns the compiled output — `None` when the compiler has nothing to change
|
||||
/// (no React-like functions, a bail-out, nothing to memoize, or a fatal error) —
|
||||
/// together with the diagnostics. Errors (e.g. Rules of Hooks violations) are
|
||||
/// hard problems in the source; the program is still left valid. Warnings
|
||||
/// include bail-outs where the compiler declined to optimize.
|
||||
/// Returns [`CompileResult::Success`] when compilation completed, even if one or
|
||||
/// more functions bailed out with diagnostics. Returns
|
||||
/// [`CompileResult::Fatal`] only when compilation was aborted according to
|
||||
/// [`PluginOptions::panic_threshold`]. Diagnostic severity and fatality are
|
||||
/// intentionally separate.
|
||||
///
|
||||
/// Must run **first**, on the pristine AST, before any other transform. The
|
||||
/// borrowed `semantic` must have been built from that same pristine AST with
|
||||
@@ -62,12 +64,14 @@ pub struct LintResult {
|
||||
/// the output with [`CompileOutput::transform`] once `semantic` is dropped:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let (output, diagnostics) = {
|
||||
/// let result = {
|
||||
/// let semantic = SemanticBuilder::new().with_build_nodes(true).build(&program).semantic;
|
||||
/// compile(&program, &semantic, &allocator, options)
|
||||
/// }; // `semantic`'s borrow of `program` ends here
|
||||
/// if let Some(output) = output {
|
||||
/// output.transform(&mut program);
|
||||
/// match result {
|
||||
/// CompileResult::Success { output: Some(output), .. } => output.transform(&mut program),
|
||||
/// CompileResult::Success { output: None, .. } => {}
|
||||
/// CompileResult::Fatal { diagnostics } => report(diagnostics),
|
||||
/// }
|
||||
/// ```
|
||||
pub fn compile<'a>(
|
||||
@@ -75,24 +79,26 @@ pub fn compile<'a>(
|
||||
semantic: &Semantic<'_>,
|
||||
allocator: &'a Allocator,
|
||||
options: PluginOptions,
|
||||
) -> (Option<CompileOutput<'a>>, Diagnostics) {
|
||||
) -> CompileResult<'a> {
|
||||
// Check for existing runtime imports (file already compiled).
|
||||
if has_memo_cache_function_import(program, &get_react_compiler_runtime_module(&options.target))
|
||||
{
|
||||
return (None, Diagnostics::default());
|
||||
return CompileResult::Success { output: None, diagnostics: Diagnostics::default() };
|
||||
}
|
||||
|
||||
// Blocklisted imports fail the whole file: report and bail without compiling.
|
||||
// Blocklisted imports bail out the whole file. Whether that aborts the
|
||||
// surrounding transform is controlled by `panic_threshold`.
|
||||
if let Some(diagnostics) =
|
||||
validate_restricted_imports(program, &options.environment.validate_blocklisted_imports)
|
||||
{
|
||||
return (None, diagnostics);
|
||||
return if diagnostics::should_panic(&diagnostics, options.panic_threshold) {
|
||||
CompileResult::Fatal { diagnostics }
|
||||
} else {
|
||||
CompileResult::Success { output: None, diagnostics }
|
||||
};
|
||||
}
|
||||
|
||||
match compile_program(allocator, semantic, program, options) {
|
||||
CompileResult::Success { output, diagnostics } => (output.map(|b| *b), diagnostics),
|
||||
CompileResult::Error { diagnostics } => (None, diagnostics),
|
||||
}
|
||||
compile_program(allocator, semantic, program, options)
|
||||
}
|
||||
|
||||
/// Lint a pre-parsed program — like [`compile`] but read-only: it collects
|
||||
@@ -109,6 +115,8 @@ pub fn lint<'a>(
|
||||
let mut options = options;
|
||||
options.no_emit = true;
|
||||
|
||||
let (_output, diagnostics) = compile(program, semantic, allocator, options);
|
||||
LintResult { diagnostics }
|
||||
match compile(program, semantic, allocator, options) {
|
||||
CompileResult::Success { diagnostics, .. } => LintResult { diagnostics, fatal: false },
|
||||
CompileResult::Fatal { diagnostics } => LintResult { diagnostics, fatal: true },
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use crate::react_compiler_hir::ReactFunctionType;
|
||||
use super::program::CompileOutput;
|
||||
|
||||
/// Main result type returned by the compile function.
|
||||
#[must_use]
|
||||
pub enum CompileResult<'a> {
|
||||
/// Compilation succeeded (or no functions needed compilation).
|
||||
/// `output` is None if no changes are to be made to the program — always so
|
||||
@@ -17,8 +18,9 @@ pub enum CompileResult<'a> {
|
||||
/// Errors and warnings accumulated during compilation.
|
||||
diagnostics: Diagnostics,
|
||||
},
|
||||
/// A fatal error occurred and panicThreshold dictates it should throw.
|
||||
Error { diagnostics: Diagnostics },
|
||||
/// Compilation was aborted because `panicThreshold` dictates that the
|
||||
/// diagnostics should be surfaced as a fatal error.
|
||||
Fatal { diagnostics: Diagnostics },
|
||||
}
|
||||
|
||||
/// Codegen output for a single compiled function.
|
||||
|
||||
@@ -20,7 +20,7 @@ use oxc_diagnostics::{Diagnostics, OxcDiagnostic};
|
||||
use oxc_span::{GetSpan, SPAN, Span};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use crate::diagnostics::{ErrorCategory, has_critical_errors, with_fallback_label};
|
||||
use crate::diagnostics::{ErrorCategory, should_panic, with_fallback_label};
|
||||
use crate::react_compiler_hir::ReactFunctionType;
|
||||
use crate::react_compiler_hir::environment_config::EnvironmentConfig;
|
||||
use crate::react_compiler_lowering::FunctionNode;
|
||||
@@ -1011,7 +1011,7 @@ fn log_error(err: &Diagnostics, fn_span: Option<Span>, diagnostics: &mut Diagnos
|
||||
}
|
||||
|
||||
/// Handle an error according to the panicThreshold setting.
|
||||
/// Returns Some(CompileResult::Error) if the error should be surfaced as fatal,
|
||||
/// Returns Some(CompileResult::Fatal) if the error should be surfaced as fatal,
|
||||
/// otherwise returns None (error was logged only).
|
||||
fn handle_error<'a>(
|
||||
err: &Diagnostics,
|
||||
@@ -1022,19 +1022,10 @@ fn handle_error<'a>(
|
||||
// Log the error
|
||||
log_error(err, fn_span, diagnostics);
|
||||
|
||||
let should_panic = match panic_threshold {
|
||||
PanicThreshold::AllErrors => true,
|
||||
PanicThreshold::CriticalErrors => has_critical_errors(err),
|
||||
PanicThreshold::None => false,
|
||||
};
|
||||
|
||||
// Config errors always cause a panic
|
||||
let is_config_error = err.iter().any(|d| ErrorCategory::Config.matches(d));
|
||||
|
||||
if should_panic || is_config_error {
|
||||
if should_panic(err, panic_threshold) {
|
||||
// The per-detail diagnostics were already pushed by `log_error`; the fatal
|
||||
// result just carries them. (The old JS-shim summary is dropped.)
|
||||
Some(CompileResult::Error { diagnostics: std::mem::take(diagnostics) })
|
||||
Some(CompileResult::Fatal { diagnostics: std::mem::take(diagnostics) })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -2958,7 +2949,11 @@ pub fn compile_program<'a>(
|
||||
Diagnostics::from(ErrorCategory::Invariant.diagnostic(
|
||||
"Unexpected compiled functions when module scope opt-out is present",
|
||||
));
|
||||
handle_error(&err, None, context.opts.panic_threshold, &mut context.diagnostics);
|
||||
if let Some(result) =
|
||||
handle_error(&err, None, context.opts.panic_threshold, &mut context.diagnostics)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return CompileResult::Success { output: None, diagnostics: context.diagnostics };
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@ use oxc_allocator::Allocator;
|
||||
use oxc_codegen::Codegen;
|
||||
use oxc_parser::Parser;
|
||||
use oxc_react_compiler::{
|
||||
BuiltInTypeRef, CompilationMode, CompilerOutputMode, DynamicGatingConfig, Effect,
|
||||
EnvironmentConfig, ExhaustiveEffectDepsMode, ExternalFunctionConfig, FunctionTypeConfig,
|
||||
FxIndexMap, GatingConfig, HookTypeConfig, InstrumentationConfig, ObjectTypeConfig,
|
||||
PanicThreshold, PluginOptions, TypeConfig, TypeReferenceConfig, ValueKind, compile, lint,
|
||||
BuiltInTypeRef, CompilationMode, CompileResult, CompilerOutputMode, DynamicGatingConfig,
|
||||
Effect, EnvironmentConfig, ExhaustiveEffectDepsMode, ExternalFunctionConfig,
|
||||
FunctionTypeConfig, FxIndexMap, GatingConfig, HookTypeConfig, InstrumentationConfig,
|
||||
ObjectTypeConfig, PanicThreshold, PluginOptions, TypeConfig, TypeReferenceConfig, ValueKind,
|
||||
compile, lint,
|
||||
};
|
||||
use oxc_semantic::SemanticBuilder;
|
||||
use oxc_span::SourceType;
|
||||
@@ -75,11 +76,15 @@ fn run_fixture(source: &str) -> String {
|
||||
// runs first on the untouched allocator so its output stays byte-identical to a
|
||||
// compile-only run; `lint` then re-runs the same pipeline read-only so we can
|
||||
// cross-check its diagnostics against `compile`'s below.
|
||||
let (output, diagnostics, lint_diagnostics) = {
|
||||
let (output, diagnostics, fatal, lint_result) = {
|
||||
let semantic = SemanticBuilder::new().with_build_nodes(true).build(&program).semantic;
|
||||
let (output, diagnostics) = compile(&program, &semantic, &allocator, options.clone());
|
||||
let lint_diagnostics = lint(&program, &semantic, &allocator, options).diagnostics;
|
||||
(output, diagnostics, lint_diagnostics)
|
||||
let (output, diagnostics, fatal) =
|
||||
match compile(&program, &semantic, &allocator, options.clone()) {
|
||||
CompileResult::Success { output, diagnostics } => (output, diagnostics, false),
|
||||
CompileResult::Fatal { diagnostics } => (None, diagnostics, true),
|
||||
};
|
||||
let lint_result = lint(&program, &semantic, &allocator, options);
|
||||
(output, diagnostics, fatal, lint_result)
|
||||
};
|
||||
let changed = output.is_some();
|
||||
if let Some(output) = output {
|
||||
@@ -92,10 +97,10 @@ fn run_fixture(source: &str) -> String {
|
||||
// compiler cleanly declines to change anything (e.g. `@expectNothingCompiled`,
|
||||
// or a file with no React-like functions), or when a lint-mode run reports
|
||||
// findings without rewriting the program, echo the reprinted source rather than
|
||||
// the `No changes.` marker. The marker is kept only when a non-lint run reports
|
||||
// an error (parse failure or compile diagnostic), where upstream emits no code —
|
||||
// and echoing a parse-recovered AST would be misleading.
|
||||
let clean = parsed.diagnostics.is_empty() && diagnostics.as_slice().is_empty();
|
||||
// the `No changes.` marker. The marker is kept only when a non-lint run is
|
||||
// fatal (a parse failure or an escalated compile diagnostic), where upstream
|
||||
// emits no code — and echoing a parse-recovered AST would be misleading.
|
||||
let clean = parsed.diagnostics.is_empty() && !fatal;
|
||||
if changed || clean || lint_mode {
|
||||
out.push_str(&Codegen::new().build(&program).code);
|
||||
} else {
|
||||
@@ -111,11 +116,12 @@ fn run_fixture(source: &str) -> String {
|
||||
// emitting. Surface any divergence in the snapshot so it stays reviewed rather than
|
||||
// drifting silently.
|
||||
let transform_body = diagnostics_body(diagnostics.as_slice());
|
||||
let lint_body = diagnostics_body(lint_diagnostics.as_slice());
|
||||
let lint_body = diagnostics_body(lint_result.diagnostics.as_slice());
|
||||
if lint_body != transform_body {
|
||||
out.push_str("\n\nLint-mode diagnostics (differ from transform):\n\n");
|
||||
out.push_str(if lint_body.is_empty() { "(none)\n" } else { &lint_body });
|
||||
}
|
||||
assert_eq!(lint_result.fatal, fatal, "lint and compile fatality diverged");
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -6,4 +6,19 @@ Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] PreserveManualMemo: Existing memoization could not be preserved", labels: One([LabeledSpan { label: Some("Could not preserve existing manual memoization"), span: SourceSpan { offset: SourceOffset(337), length: 21 }, primary: false }]), help: Some("React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `value`, but the source dependencies were []. Inferred dependency not present in source"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly @validateExhaustiveMemoizationDependencies:false
|
||||
import { useMemo } from "react";
|
||||
import { identity } from "shared-runtime";
|
||||
function Foo({ value }) {
|
||||
"use memo if(getTrue)";
|
||||
const initialValue = useMemo(() => identity(value), []);
|
||||
return <>
|
||||
<div>initial value {initialValue}</div>
|
||||
<div>current value {value}</div>
|
||||
</>;
|
||||
}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Foo,
|
||||
params: [{ value: 1 }],
|
||||
sequentialRenders: [{ value: 1 }, { value: 2 }]
|
||||
};
|
||||
+9
-1
@@ -6,4 +6,12 @@ Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Gating: Dynamic gating directive is not a valid JavaScript identifier", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(70), length: 74 }, primary: false }]), help: Some("Found 'use memo if(true)'"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none"
|
||||
function Foo() {
|
||||
"use memo if(true)";
|
||||
return <div>hello world</div>;
|
||||
}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Foo,
|
||||
params: [{}]
|
||||
};
|
||||
+10
-1
@@ -6,4 +6,13 @@ Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Gating: Multiple dynamic gating directives found", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(86), length: 104 }, primary: false }]), help: Some("Expected a single directive but found [use memo if(getTrue), use memo if(getFalse)]"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @loggerTestOnly
|
||||
function Foo() {
|
||||
"use memo if(getTrue)";
|
||||
"use memo if(getFalse)";
|
||||
return <div>hello world</div>;
|
||||
}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Foo,
|
||||
params: [{}]
|
||||
};
|
||||
@@ -7,4 +7,19 @@ Diagnostics:
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Todo: Support functions with unreachable code that may contain hoisted declarations", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(177), length: 65 }, primary: false }]), help: None, note: None, severity: Warning, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Immutability: This value cannot be modified", labels: One([LabeledSpan { label: Some("value cannot be modified"), span: SourceSpan { offset: SourceOffset(116), length: 5 }, primary: false }]), help: Some("Modifying component props or hook arguments is not allowed. Consider using a local variable instead"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
// @panicThreshold:"none"
|
||||
import { useNoAlias } from "shared-runtime";
|
||||
const cond = true;
|
||||
function useFoo(props) {
|
||||
props.x = 10;
|
||||
if (cond) bar();
|
||||
return useNoAlias({});
|
||||
function bar() {
|
||||
console.log("bar called");
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: useFoo,
|
||||
params: [{}]
|
||||
};
|
||||
+11
-1
@@ -6,4 +6,14 @@ Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Globals: Cannot reassign variables declared outside of the component/hook", labels: One([LabeledSpan { label: Some("`someGlobal` cannot be reassigned"), span: SourceSpan { offset: SourceOffset(130), length: 10 }, primary: false }]), help: Some("Variable `someGlobal` is declared outside of the component/hook. Reassigning this value during render is a form of side effect, which can cause unpredictable behavior depending on when the component happens to re-render. If this variable is used in rendering, use useState instead. Otherwise, consider updating it in an effect. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
// @gating @panicThreshold:"none" @compilationMode:"annotation"
|
||||
let someGlobal = "joe";
|
||||
function Component() {
|
||||
"use forget";
|
||||
someGlobal = "wat";
|
||||
return null;
|
||||
}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: []
|
||||
};
|
||||
+10
-1
@@ -6,4 +6,13 @@ Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Globals: Cannot reassign variables declared outside of the component/hook", labels: One([LabeledSpan { label: Some("`someGlobal` cannot be reassigned"), span: SourceSpan { offset: SourceOffset(109), length: 10 }, primary: false }]), help: Some("Variable `someGlobal` is declared outside of the component/hook. Reassigning this value during render is a form of side effect, which can cause unpredictable behavior depending on when the component happens to re-render. If this variable is used in rendering, use useState instead. Otherwise, consider updating it in an effect. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)"), note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
// @gating @panicThreshold:"none" @compilationMode:"infer"
|
||||
let someGlobal = "joe";
|
||||
function Component() {
|
||||
someGlobal = "wat";
|
||||
return <div>{someGlobal}</div>;
|
||||
}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: []
|
||||
};
|
||||
+9
-1
@@ -7,4 +7,12 @@ 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(130), 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 } }
|
||||
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(130), 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.
|
||||
// @panicThreshold:"none" @validateExhaustiveMemoizationDependencies:false
|
||||
// unclosed disable rule should affect all components
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
function ValidComponent1(props) {
|
||||
return <div>Hello World!</div>;
|
||||
}
|
||||
function ValidComponent2(props) {
|
||||
return <div>{props.greeting}</div>;
|
||||
}
|
||||
+12
-1
@@ -6,4 +6,15 @@ Diagnostics:
|
||||
|
||||
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.
|
||||
import { useRef } from "react";
|
||||
function Component() {
|
||||
"use no forget";
|
||||
const ref = useRef(null);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
ref.current = "bad";
|
||||
return <button ref={ref} />;
|
||||
}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: []
|
||||
};
|
||||
@@ -8,7 +8,7 @@ use oxc_parser::Parser;
|
||||
use oxc_semantic::SemanticBuilder;
|
||||
use oxc_span::SourceType;
|
||||
|
||||
use oxc_react_compiler::{PanicThreshold, PluginOptions, compile};
|
||||
use oxc_react_compiler::{CompileResult, PanicThreshold, PluginOptions, compile};
|
||||
|
||||
fn options() -> PluginOptions {
|
||||
PluginOptions::default()
|
||||
@@ -17,6 +17,7 @@ fn options() -> PluginOptions {
|
||||
struct TransformResult {
|
||||
changed: bool,
|
||||
diagnostics: Diagnostics,
|
||||
fatal: bool,
|
||||
}
|
||||
|
||||
/// Parse `source_text` then run the compiler in place, returning the
|
||||
@@ -28,15 +29,23 @@ fn transform_source<'a>(
|
||||
options: PluginOptions,
|
||||
) -> (Program<'a>, TransformResult) {
|
||||
let mut program = Parser::new(allocator, source_text, source_type).parse().program;
|
||||
let (output, diagnostics) = {
|
||||
let result = {
|
||||
let semantic = SemanticBuilder::new().with_build_nodes(true).build(&program).semantic;
|
||||
compile(&program, &semantic, allocator, options)
|
||||
};
|
||||
let changed = output.is_some();
|
||||
if let Some(output) = output {
|
||||
output.transform(&mut program);
|
||||
}
|
||||
(program, TransformResult { changed, diagnostics })
|
||||
let result = match result {
|
||||
CompileResult::Success { output, diagnostics } => {
|
||||
let changed = output.is_some();
|
||||
if let Some(output) = output {
|
||||
output.transform(&mut program);
|
||||
}
|
||||
TransformResult { changed, diagnostics, fatal: false }
|
||||
}
|
||||
CompileResult::Fatal { diagnostics } => {
|
||||
TransformResult { changed: false, diagnostics, fatal: true }
|
||||
}
|
||||
};
|
||||
(program, result)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,6 +96,7 @@ fn default_eslint_suppressions_do_not_bail_out() {
|
||||
transform_source(source, SourceType::tsx(), &allocator, PluginOptions::default());
|
||||
|
||||
assert!(result.changed, "{kind} must not prevent compilation");
|
||||
assert!(!result.fatal, "{kind} must not produce a fatal result");
|
||||
assert!(
|
||||
!result.diagnostics.has_errors(),
|
||||
"{kind} produced unexpected diagnostics: {:?}",
|
||||
@@ -103,6 +113,7 @@ fn flow_suppressions_still_bail_out_by_default() {
|
||||
transform_source(source, SourceType::tsx(), &allocator, PluginOptions::default());
|
||||
|
||||
assert!(!result.changed, "Flow suppression must prevent compilation");
|
||||
assert!(!result.fatal, "Flow suppression must be a nonfatal bail-out by default");
|
||||
assert_eq!(result.diagnostics.len(), 1);
|
||||
assert!(result.diagnostics[0].message.contains("[ReactCompiler] Suppression"));
|
||||
}
|
||||
@@ -130,6 +141,10 @@ fn eslint_suppressions_bail_out_when_either_internal_validation_is_disabled() {
|
||||
!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"));
|
||||
}
|
||||
@@ -221,6 +236,7 @@ fn all_errors_makes_enabled_eslint_suppressions_fatal() {
|
||||
options.environment.validate_exhaustive_memoization_dependencies = false;
|
||||
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
|
||||
|
||||
assert!(result.fatal, "all_errors must escalate suppression diagnostics");
|
||||
assert!(!result.changed, "a fatal result must not produce a rewrite");
|
||||
assert!(result.diagnostics.has_errors());
|
||||
}
|
||||
|
||||
@@ -21,13 +21,18 @@ const result = transformSync(
|
||||
},
|
||||
);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
if (result.fatal) {
|
||||
console.error(result.errors);
|
||||
} else {
|
||||
console.log(result.code);
|
||||
}
|
||||
|
||||
console.log(result.code);
|
||||
```
|
||||
|
||||
`errors` contains every diagnostic reported by parsing, the React Compiler, and
|
||||
the downstream transform. Some React Compiler bail-outs have error severity but
|
||||
are nonfatal under the default `panicThreshold`; check `fatal` to decide whether
|
||||
the transform emitted usable code.
|
||||
|
||||
The React Compiler options use the same names as
|
||||
`babel-plugin-react-compiler`/`react-compiler-napi`, including
|
||||
`compilationMode`, `panicThreshold`, `target`, `gating`, `outputMode`,
|
||||
|
||||
Vendored
+2
@@ -186,6 +186,8 @@ export interface TransformOptions {
|
||||
|
||||
/** Result returned by the React Compiler transform. */
|
||||
export interface TransformResult {
|
||||
/** Whether the transform was aborted without emitting code. */
|
||||
fatal: boolean
|
||||
/**
|
||||
* Transformed JavaScript code.
|
||||
*
|
||||
|
||||
@@ -29,7 +29,7 @@ use oxc::{
|
||||
transformer::Transformer,
|
||||
};
|
||||
use oxc_napi::{OxcError, get_source_type};
|
||||
use oxc_react_compiler::compile as react_compiler_compile;
|
||||
use oxc_react_compiler::{CompileResult, compile as react_compiler_compile};
|
||||
use oxc_sourcemap::napi::SourceMap;
|
||||
|
||||
pub use crate::options::*;
|
||||
@@ -38,6 +38,9 @@ pub use crate::options::*;
|
||||
#[derive(Default)]
|
||||
#[napi(object)]
|
||||
pub struct TransformResult {
|
||||
/// Whether the transform was aborted without emitting code.
|
||||
pub fatal: bool,
|
||||
|
||||
/// Transformed JavaScript code.
|
||||
///
|
||||
/// This is empty when parsing, semantic analysis, option validation, or the
|
||||
@@ -68,6 +71,7 @@ fn transform_impl(
|
||||
Ok(options) => options,
|
||||
Err(error) => {
|
||||
return TransformResult {
|
||||
fatal: true,
|
||||
errors: OxcError::from_diagnostics(filename, source_text, [error]),
|
||||
..TransformResult::default()
|
||||
};
|
||||
@@ -76,8 +80,12 @@ fn transform_impl(
|
||||
|
||||
let allocator = Allocator::default();
|
||||
let parser_return = Parser::new(&allocator, source_text, source_type).parse();
|
||||
let parser_has_errors = parser_return.diagnostics.has_errors();
|
||||
let mut diagnostics = parser_return.diagnostics;
|
||||
let mut program = parser_return.program;
|
||||
if parser_has_errors {
|
||||
return error_result(filename, source_text, diagnostics);
|
||||
}
|
||||
|
||||
let SemanticBuilderReturn { semantic, diagnostics: semantic_diagnostics } =
|
||||
SemanticBuilder::new_compiler()
|
||||
@@ -90,13 +98,15 @@ fn transform_impl(
|
||||
return error_result(filename, source_text, diagnostics);
|
||||
}
|
||||
|
||||
let (react_output, react_diagnostics) = react_compiler_options.map_or_else(
|
||||
|| (None, Diagnostics::new()),
|
||||
|options| react_compiler_compile(&program, &semantic, &allocator, options),
|
||||
);
|
||||
let react_has_errors = react_diagnostics.has_errors();
|
||||
let (react_output, react_diagnostics, react_fatal) = match react_compiler_options {
|
||||
None => (None, Diagnostics::new(), false),
|
||||
Some(options) => match react_compiler_compile(&program, &semantic, &allocator, options) {
|
||||
CompileResult::Success { output, diagnostics } => (output, diagnostics, false),
|
||||
CompileResult::Fatal { diagnostics } => (None, diagnostics, true),
|
||||
},
|
||||
};
|
||||
diagnostics.extend(react_diagnostics);
|
||||
if react_has_errors {
|
||||
if react_fatal {
|
||||
return error_result(filename, source_text, diagnostics);
|
||||
}
|
||||
|
||||
@@ -122,6 +132,7 @@ fn transform_impl(
|
||||
})
|
||||
.build(&program);
|
||||
TransformResult {
|
||||
fatal: false,
|
||||
code: codegen_return.code,
|
||||
map: codegen_return.map.map(SourceMap::from),
|
||||
errors: OxcError::from_diagnostics(filename, source_text, diagnostics),
|
||||
@@ -130,6 +141,7 @@ fn transform_impl(
|
||||
|
||||
fn error_result(filename: &str, source_text: &str, diagnostics: Diagnostics) -> TransformResult {
|
||||
TransformResult {
|
||||
fatal: true,
|
||||
errors: OxcError::from_diagnostics(filename, source_text, diagnostics),
|
||||
..TransformResult::default()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("transformSync", () => {
|
||||
it("runs React Compiler before TypeScript and JSX transforms", () => {
|
||||
const result = transformSync("Component.tsx", fixture);
|
||||
|
||||
expect(result.fatal).toBe(false);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.code).toContain("react/compiler-runtime");
|
||||
expect(result.code).toContain("_c(");
|
||||
@@ -100,11 +101,20 @@ describe("transformSync", () => {
|
||||
],
|
||||
])("reports an invalid %s option without emitting code", (option, options) => {
|
||||
const result = transformSync("Component.tsx", fixture, options as never);
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result.code).toBe("");
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toContain(`Invalid React Compiler \`${option}\` option`);
|
||||
});
|
||||
|
||||
it("marks parse errors as fatal", () => {
|
||||
const result = transformSync("Component.tsx", "function Component(");
|
||||
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result.code).toBe("");
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("supports source maps and language overrides", () => {
|
||||
const result = transformSync("Component.vue", fixture, {
|
||||
lang: "tsx",
|
||||
@@ -176,9 +186,112 @@ describe("transformSync", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.fatal).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
severity: "Error",
|
||||
message: expect.stringContaining("[ReactCompiler] Suppression:"),
|
||||
});
|
||||
expect(result.code).not.toBe("");
|
||||
expect(result.code).not.toContain("react/compiler-runtime");
|
||||
expect(result.code).not.toContain(": number");
|
||||
expect(result.code).not.toContain("<div");
|
||||
});
|
||||
|
||||
it("continues after an incompatible library bailout", () => {
|
||||
const result = transformSync(
|
||||
"Components.tsx",
|
||||
`import { useReactTable } from "@tanstack/react-table";
|
||||
function Table() {
|
||||
const table = useReactTable({});
|
||||
return <div>{table}</div>;
|
||||
}
|
||||
export function Component(props: { text: string }) {
|
||||
return <span>{props.text}</span>;
|
||||
}`,
|
||||
);
|
||||
|
||||
expect(result.fatal).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
severity: "Warning",
|
||||
message: "[ReactCompiler] IncompatibleLibrary: Use of incompatible library",
|
||||
});
|
||||
expect(result.errors.some((error) => error.message.includes("Unexpected error"))).toBe(false);
|
||||
expect(result.code).toContain("react/compiler-runtime");
|
||||
expect(result.code).not.toContain("props: { text: string }");
|
||||
expect(result.code).not.toContain("<span");
|
||||
});
|
||||
|
||||
it("compiles an unsuppressed sibling after a suppression bailout", () => {
|
||||
const result = transformSync(
|
||||
"Components.tsx",
|
||||
`function Suppressed({ value }: { value: number }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const doubled = value * 2;
|
||||
return <div>{doubled}</div>;
|
||||
}
|
||||
export function Component(props: { text: string }) {
|
||||
return <span>{props.text}</span>;
|
||||
}`,
|
||||
{
|
||||
environment: {
|
||||
validateExhaustiveMemoizationDependencies: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.fatal).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toContain("[ReactCompiler] Suppression:");
|
||||
expect(result.code).toContain("react/compiler-runtime");
|
||||
expect(result.code).not.toContain("props: { text: string }");
|
||||
expect(result.code).not.toContain("<span");
|
||||
});
|
||||
|
||||
it.each(["critical_errors", "all_errors"] as const)(
|
||||
"makes suppression bailouts fatal at panicThreshold %s",
|
||||
(panicThreshold) => {
|
||||
const result = transformSync(
|
||||
"Component.tsx",
|
||||
`function Component({ value }: { value: number }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const doubled = value * 2;
|
||||
return <div>{doubled}</div>;
|
||||
}`,
|
||||
{
|
||||
panicThreshold,
|
||||
environment: {
|
||||
validateExhaustiveMemoizationDependencies: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result.code).toBe("");
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toContain("[ReactCompiler] Suppression:");
|
||||
},
|
||||
);
|
||||
|
||||
it("makes warning bailouts fatal at panicThreshold all_errors", () => {
|
||||
const result = transformSync(
|
||||
"Table.tsx",
|
||||
`import { useReactTable } from "@tanstack/react-table";
|
||||
function Table() {
|
||||
const table = useReactTable({});
|
||||
return <div>{table}</div>;
|
||||
}`,
|
||||
{ panicThreshold: "all_errors" },
|
||||
);
|
||||
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result.code).toBe("");
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
severity: "Warning",
|
||||
message: "[ReactCompiler] IncompatibleLibrary: Use of incompatible library",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use oxc_allocator::Allocator;
|
||||
use oxc_benchmark::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||
use oxc_parser::{Parser, ParserReturn};
|
||||
use oxc_react_compiler::{PluginOptions, compile};
|
||||
use oxc_react_compiler::{CompileResult, PluginOptions, compile};
|
||||
use oxc_semantic::SemanticBuilder;
|
||||
use oxc_tasks_common::TestFiles;
|
||||
|
||||
@@ -30,15 +30,20 @@ fn bench_react_compiler(criterion: &mut Criterion) {
|
||||
Parser::new(&allocator, source_text, source_type).parse();
|
||||
|
||||
runner.run(|| {
|
||||
let (output, diagnostics) = {
|
||||
let result = {
|
||||
let semantic =
|
||||
SemanticBuilder::new().with_build_nodes(true).build(&program).semantic;
|
||||
compile(&program, &semantic, &allocator, options.clone())
|
||||
};
|
||||
if let Some(output) = output {
|
||||
output.transform(&mut program);
|
||||
match result {
|
||||
CompileResult::Success { output, diagnostics } => {
|
||||
if let Some(output) = output {
|
||||
output.transform(&mut program);
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
CompileResult::Fatal { diagnostics } => diagnostics,
|
||||
}
|
||||
diagnostics
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user