mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(react_compiler): propagate codegen invariants instead of emitting empty bodies (#24128)
## Summary
This batch targets 5 fixtures that **compiled without error in oxc but
throw an internal `Invariant` upstream** — cases where oxc's output was
*silently wrong* (worse than upstream's loud failure).
### Root cause (shared by 4 of the 5)
`codegen_function` wrapped `ox_codegen_reactive_function` in a
scaffolding shim that fell back to an **empty function body on ANY
codegen error**. That turned genuine "this input cannot be compiled"
invariants into silent miscompiles: the whole body was replaced with
`{}` and applied to the program with no diagnostic.
The shim still had a legitimate job, though — a couple of codegen
sub-emitters aren't ported yet (`Destructuring reassignment targets`,
hook-guard wrapping), and upstream *does* compile those. Blindly
propagating every error would regress ~26 fixtures that currently emit
an (imperfect) empty body but would now spuriously error.
**Fix:** split the two error classes. A new
`CompilerError::unimplemented` flag (set via `unimplemented_err`) marks
the genuinely-unported sub-emitters; the shim keeps falling back to an
empty body only for those. Every other codegen error now propagates as a
diagnostic — matching upstream's `Invariant` — instead of emitting wrong
code. Net diff: only the 5 genuine-invariant fixtures change; the ~26
not-yet-ported fixtures keep their prior output (no regressions).
## Per-fixture status
| Fixture | Status | Notes |
| --- | --- | --- |
| `error.bug-invariant-unnamed-temporary` | **fixed → now-errors** |
matches upstream |
| `error.bug-invariant-codegen-methodcall` | **fixed → now-errors** |
matches upstream |
| `error.call-args-destructuring-asignment-complex` | **fixed →
now-errors** | matches upstream |
| `error.todo-nested-method-calls-lower-property-load-into-temporary` |
**fixed → now-errors** | matches upstream |
| `error.bug-invariant-local-or-context-references` | **fixed → correct
output** | oxc handles a case upstream crashes on |
Bonus: `error.todo-kitchensink` also flipped from a silent empty body
(masked behind 8 accumulated warnings) to the single upstream invariant
`Expected a variable declaration`.
### Details
**`error.bug-invariant-unnamed-temporary`** — `renderer={(...props) =>
<span {...props}>{displayValue}</span>}`
Was silently compiled with the renderer arrow dropped to `function
_temp() {}` (empty body). The codegen path hit `Expected temporaries to
be promoted to named identifiers in an earlier pass` (an unnamed
identifier reaching `convert_identifier`), which the shim swallowed. Now
the error propagates and the function is left unchanged — identical to
upstream's `Invariant`.
**`error.bug-invariant-codegen-methodcall`** — `Math.floor(diff.bar())`
Was silently compiled to `const YearsAndMonthsSince = () => {};` (entire
body dropped). `diff.bar`'s property had been promoted/lowered into an
`Identifier` so codegen's `MethodCall::property must be an unpromoted +
unmemoized MemberExpression` invariant fired (`Got: 'Identifier'`) and
was swallowed. Now propagates — matches upstream.
**`error.todo-nested-method-calls-lower-property-load-into-temporary`**
— `Math.max(2, items.push(5), ...other)`
Same `MethodCall::property` invariant as above (`items.push` property
lowered into a temporary), same silent empty-body drop. Now propagates —
matches upstream.
**`error.call-args-destructuring-asignment-complex`** — `x.foo(([[x]] =
makeObject()))`
Was silently compiled to `function Component() {}` (body dropped). The
complex destructuring assignment produced a const-declaration
instruction that is *also referenced as an expression*, tripping
codegen's `Const declaration cannot be referenced as an expression`
invariant (`this is Const`), which was swallowed. Now propagates —
matches upstream.
**`error.bug-invariant-local-or-context-references`** — `catch (err) {
setState(_prev => ({..., error: err})); }`
Upstream throws `Expected all references to a variable to be
consistently local or context references` (it mis-classifies a catch
binding captured by a lambda as a context variable even though `err` is
never reassigned). **oxc already lowers this correctly**: it renames the
catch param and aliases it — `catch (t0) { const err = t0; setState(...)
}` — so `err` stays a plain read-only local and no inconsistency arises.
Verified semantically equivalent to the source on both the try-success
and catch paths (identical `setState` call sequences, including error
propagation). No code change needed — oxc legitimately handles a case
upstream crashes on.
## Notes / still-open
- The 4 now-erroring fixtures are cases upstream *also* fails on (its
own passes emit these invariants). Producing *correct* memoized output
for them would require fixing the earlier promotion/SSA passes so
temporaries are named/unpromoted before codegen — upstream doesn't do
this either, so erroring cleanly is the correct, achievable resolution
here.
- `todo-kitchensink`'s oxc message is `Invariant: Expected a variable
declaration` (upstream adds a `Got ExpressionStatement.` description);
the reason matches, the description is a cosmetic gap.
This commit is contained in:
1 parent
e0e652cd6e
commit
b82c394e2f
7 files changed
+57
-44
No files matched your search
@@ -241,6 +241,13 @@ pub struct CompilerError {
|
||||
/// because errors created directly (e.g., via `?` from a pass) are
|
||||
/// analogous to thrown errors in the TS code.
|
||||
pub is_thrown: bool,
|
||||
/// Set when the error originates from an oxc codegen sub-emitter that has not
|
||||
/// yet been ported (e.g. destructuring reassignment targets, hook-guard
|
||||
/// wrapping). `codegen_function` swallows these and falls back to an empty
|
||||
/// body — preserving the pre-port behavior — instead of surfacing a spurious
|
||||
/// diagnostic for a construct the upstream compiler handles. Genuine invariant
|
||||
/// errors leave this `false` and propagate as diagnostics.
|
||||
pub unimplemented: bool,
|
||||
}
|
||||
|
||||
/// Either a new-style diagnostic or legacy error detail
|
||||
@@ -268,7 +275,7 @@ impl CompilerErrorOrDiagnostic {
|
||||
|
||||
impl CompilerError {
|
||||
pub fn new() -> Self {
|
||||
Self { details: Vec::new(), is_thrown: true }
|
||||
Self { details: Vec::new(), is_thrown: true, unimplemented: false }
|
||||
}
|
||||
|
||||
pub fn push_diagnostic(&mut self, diagnostic: CompilerDiagnostic) {
|
||||
|
||||
+28
-12
@@ -108,16 +108,19 @@ pub fn codegen_function<'a, 'h>(
|
||||
fbt_operands,
|
||||
);
|
||||
|
||||
// The value-emission port covers most instruction kinds, but a few sub-emitters
|
||||
// (function/object/JSX expressions, hook-guard wrapping, TS-type reparse) are
|
||||
// deferred to later batches and currently raise an invariant error. Until they
|
||||
// land — and until the emitted body is actually spliced into the program — fall
|
||||
// back to an empty body on any emission error, preserving the pre-batch behavior
|
||||
// (the original program is returned un-memoized by `compile_program`) without
|
||||
// surfacing spurious diagnostics. This shim is removed once emission is complete.
|
||||
// A few codegen sub-emitters (destructuring reassignment targets, hook-guard
|
||||
// wrapping) are not yet ported and raise an `unimplemented` error. For those,
|
||||
// fall back to an empty body so the function is emitted un-memoized instead of
|
||||
// surfacing a spurious diagnostic for a construct the upstream compiler handles.
|
||||
//
|
||||
// Genuine invariant errors (unnamed temporaries, MethodCall property must be an
|
||||
// unmemoized MemberExpression, const/let referenced as an expression, ...) are
|
||||
// NOT `unimplemented` and propagate as diagnostics — matching the upstream
|
||||
// compiler, which throws an Invariant on these inputs rather than silently
|
||||
// emitting wrong code.
|
||||
let mut compiled = match ox_codegen_reactive_function(&mut cx, func) {
|
||||
Ok(compiled) => compiled,
|
||||
Err(_) => OxcCompiledFunction {
|
||||
Err(err) if err.unimplemented => OxcCompiledFunction {
|
||||
params: oxc_ast::ast::FormalParameters::boxed(
|
||||
SPAN,
|
||||
oxc_ast::ast::FormalParameterKind::FormalParameter,
|
||||
@@ -139,6 +142,7 @@ pub fn codegen_function<'a, 'h>(
|
||||
pruned_memo_blocks: 0,
|
||||
pruned_memo_values: 0,
|
||||
},
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let cache_count = compiled.memo_slots_used;
|
||||
@@ -2573,9 +2577,10 @@ fn ox_binding_pattern_to_assignment_target<'a>(
|
||||
oxc_ast::ast::IdentifierReference::boxed(SPAN, id.name, &cx.ast),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
Err(invariant_err("Destructuring reassignment targets are not yet ported to oxc", None))
|
||||
}
|
||||
_ => Err(unimplemented_err(
|
||||
"Destructuring reassignment targets are not yet ported to oxc",
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3161,7 +3166,7 @@ fn ox_maybe_wrap_hook_call<'a>(
|
||||
if cx.env.hook_guard_name.is_some()
|
||||
&& cx.env.output_mode == crate::react_compiler_hir::environment::OutputMode::Client
|
||||
{
|
||||
return Err(invariant_err(
|
||||
return Err(unimplemented_err(
|
||||
"Hook guard wrapping in oxc codegen is not yet ported (deferred to a later batch)",
|
||||
None,
|
||||
));
|
||||
@@ -3380,6 +3385,17 @@ fn invariant(
|
||||
if !condition { Err(invariant_err(reason, loc)) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Construct an error for a codegen sub-emitter that has not yet been ported. Unlike
|
||||
/// [`invariant_err`], `codegen_function` catches these (via `CompilerError::unimplemented`)
|
||||
/// and falls back to an empty body rather than surfacing a spurious diagnostic for a
|
||||
/// construct the upstream compiler handles. Use this only for genuinely-unported paths,
|
||||
/// never for real invariants (which must propagate and fail loudly like upstream).
|
||||
fn unimplemented_err(reason: &str, loc: Option<DiagSourceLocation>) -> CompilerError {
|
||||
let mut err = invariant_err(reason, loc);
|
||||
err.unimplemented = true;
|
||||
err
|
||||
}
|
||||
|
||||
fn invariant_err(reason: &str, loc: Option<DiagSourceLocation>) -> CompilerError {
|
||||
// Use CompilerDiagnostic (with details array) to match TS CompilerError.invariant()
|
||||
let mut err = CompilerError::new();
|
||||
|
||||
+5
-1
@@ -2,4 +2,8 @@
|
||||
source: crates/oxc_react_compiler/tests/snapshot.rs
|
||||
input_file: crates/oxc_react_compiler/fixtures/error.bug-invariant-codegen-methodcall.js
|
||||
---
|
||||
const YearsAndMonthsSince = () => {};
|
||||
Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Invariant: [Codegen] Internal error: MethodCall::property must be an unpromoted + unmemoized MemberExpression", labels: One([LabeledSpan { label: Some("Got: 'Identifier'"), span: SourceSpan { offset: SourceOffset(75), length: 10 }, primary: false }]), help: None, note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
+5
-14
@@ -2,17 +2,8 @@
|
||||
source: crates/oxc_react_compiler/tests/snapshot.rs
|
||||
input_file: crates/oxc_react_compiler/fixtures/error.bug-invariant-unnamed-temporary.js
|
||||
---
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import Bar from "./Bar";
|
||||
export function Foo() {
|
||||
const $ = _c(1);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = <Bar renderer={_temp} />;
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
function _temp() {}
|
||||
Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Invariant: Expected temporaries to be promoted to named identifiers in an earlier pass", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(33), length: 146 }, primary: false }]), help: None, note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
+5
-1
@@ -2,4 +2,8 @@
|
||||
source: crates/oxc_react_compiler/tests/snapshot.rs
|
||||
input_file: crates/oxc_react_compiler/fixtures/error.call-args-destructuring-asignment-complex.js
|
||||
---
|
||||
function Component() {}
|
||||
Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Invariant: Const declaration cannot be referenced as an expression", labels: One([LabeledSpan { label: Some("this is Const"), span: SourceSpan { offset: SourceOffset(61), length: 5 }, primary: false }]), help: None, note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
@@ -4,13 +4,6 @@ input_file: crates/oxc_react_compiler/fixtures/error.todo-kitchensink.js
|
||||
---
|
||||
Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Todo: (BuildHIR::lowerStatement) Handle var kinds in VariableDeclaration", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(73), length: 11 }, primary: false }]), help: None, note: None, severity: Warning, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] UnsupportedSyntax: Inline `class` declarations are not supported", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(88), length: 102 }, primary: false }]), help: Some("Move class declarations outside of components/hooks"), note: None, severity: Warning, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Todo: (BuildHIR::lowerStatement) Handle empty test in ForStatement", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(465), length: 25 }, primary: false }]), help: None, note: None, severity: Warning, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Todo: (BuildHIR::lowerExpression) Handle tagged template where cooked value is different from raw value", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(520), length: 14 }, primary: false }]), help: None, note: None, severity: Warning, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Todo: (BuildHIR::node.lowerReorderableExpression) Expression type `MemberExpression` cannot be safely reordered", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(858), length: 3 }, primary: false }]), help: None, note: None, severity: Warning, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Todo: (BuildHIR::node.lowerReorderableExpression) Expression type `BinaryExpression` cannot be safely reordered", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(810), length: 5 }, primary: false }]), help: None, note: None, severity: Warning, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Globals: Cannot reassign variables declared outside of the component/hook", labels: One([LabeledSpan { label: Some("`v` cannot be reassigned"), span: SourceSpan { offset: SourceOffset(571), length: 1 }, primary: false }]), help: Some("Variable `v` 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 } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Globals: Cannot reassign variables declared outside of the component/hook", labels: One([LabeledSpan { label: Some("`v` cannot be reassigned"), span: SourceSpan { offset: SourceOffset(603), length: 1 }, primary: false }]), help: Some("Variable `v` 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 } }
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Invariant: Expected a variable declaration", labels: One([LabeledSpan { label: None, span: SourceSpan { offset: SourceOffset(0), length: 1034 }, primary: false }]), help: None, note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
+5
-7
@@ -2,10 +2,8 @@
|
||||
source: crates/oxc_react_compiler/tests/snapshot.rs
|
||||
input_file: crates/oxc_react_compiler/fixtures/error.todo-nested-method-calls-lower-property-load-into-temporary.js
|
||||
---
|
||||
import { makeArray } from "shared-runtime";
|
||||
const other = [0, 1];
|
||||
function Component() {}
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{}]
|
||||
};
|
||||
Diagnostics:
|
||||
|
||||
OxcDiagnostic { inner: OxcDiagnosticInner { message: "[ReactCompiler] Invariant: [Codegen] Internal error: MethodCall::property must be an unpromoted + unmemoized MemberExpression", labels: One([LabeledSpan { label: Some("Got: 'Identifier'"), span: SourceSpan { offset: SourceOffset(159), length: 8 }, primary: false }]), help: None, note: None, severity: Error, code: OxcCode { scope: None, number: None }, url: None } }
|
||||
|
||||
No changes.
|
||||
Reference in new issue
Block a user