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:
Boshen authored and GitHub committed 2026-07-04 10:32:06 +08:00
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) {
@@ -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();
@@ -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.
@@ -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.
@@ -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.
@@ -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.