Files
4deb1057b8 [compiler] Port React Compiler to Rust (#36173)
This is an experimental, work-in-progress port of React Compiler to
Rust. Key points:

* Work-in-progress - we are sharing early, prior to testing internally
at Meta, to get feedback from partners in parallel with continued
development.
* No builds available yet, you'll have to do some hacking if you want to
try this.
* All fixtures pass, no known gaps but there may be lurking bugs.
* The architecture was heavily guided by humans (me, @josephsavona) but
majority coded by AI. I was very hands-on in setting the architecture,
the testing and verification strategy, incremental migration approach,
etc. I also kept a close eye on the code and spent a decent amount of
time going back and forth to get code quality to a decent level.
* The public API is basically "Rust Babel AST" + Scope Info in, Rust
Babel AST out. We use a Rust representation of the Babel AST as our
"public API", as it were, and then each integration (Babel, OXC, SWC)
converts to/from their native representation. For now integrations must
also provide scope information - in the future React Compiler may
compute bindings and references itself from the AST.
* Internally, the Rust version uses the same architecture as the
TypeScript version. The compiler converts from the AST into our own
intermediate representation (HIR, short for High-level Intermediate
Representation) which uses a control-flow graph (CFG) and single-static
assignment (SSA). We go through the same series of passes, with the same
overall algorithms. It's very much a pass-by-pass port. The main
differences are in the data representation - using arena-like structures
(and indices into these arenas) to work within Rust's borrowing system.
* Early performance numbers are derived from AI and i haven't spent much
time validating the benchmark setup, beyond the fact that the
optimization opportunities it discovered made complete sense and the
fixes were right. With that caveat, itt does appear that the Rust
version is quite fast already: 3x faster when operating as a Babel
plugin. The serialization cost is quite high, but the actual
transformation logic is ~10x faster, so it's net faster. Native
integrations (oxc, swc) should be even faster.
* There are 3 integrations right now: an alternative Babel plugin (which
will eventually get removed as we integrate into
babel-plugin-react-compiler), and examples of what OXC and SWC
integrations could look like (see react_compiler_oxc and
react_compiler_swc crates).

correctness:
* all 1725 fixtures pass in snap when comparing the temporary rust
version of the plugin with the main version. this compares generated
code output as well as errors.
* all fixtures also pass a full comparison of the per-pass compiler
intermediate representation — the intermediate state (including log
events and errors) are ~identical after every single pass (modulo some
normalization of ids)
* The OXC and SWC example integrations seem to be working well, though i
haven't manually verified this to the same extent as i have the Babel
integration.

development:
* `yarn snap --rust` is the primary test suite, testing that we error or
compile as expected. It does not test the inner state of the compiler
along the way, though, making it less suitable for finding subtle logic
gaps btw the TS and Rust versions. It's also Babel based, making it less
easy to test OXC and SWC integrations.
* `compiler/scripts/test-e2e.sh` is an e2e test of all 3 variants (babel
wrapper around Rust, OXC/SWC integrations) against the TS
implementation. This does a partial comparison, focused on final output
code only (doesn't test error details etc). Useful for getting the swc
and oxc integrations closer to parity.
* `compiler/script/test-rust-port.sh` does detailed testing of the
internal compiler state after each pass, in addition to checking the
final output code. This is the key script used to port the compiler,
ensuring not just that the output was the same but that each pass was
capturing all the same detail. This script can be pointed at *any*
directory of JS files, which we expect to use for internal testing at
Meta.


## For Partners

We're excited to partner with teams to integrate the Rust version of
React Compiler into other tools, like OXC and SWC. If you're interested
in working with us on this, the best place to start is by taking a look
at the react_compiler_swc and react_compiler_oxc crates. These give you
an idea of the API shape that we're thinking of.

Note that the conversion from any AST into our HIR is complex, and we
can only maintain one version. Hence we've aligned on using a Babel-like
AST as our public API. Another key point is that we don't yet implement
our own scope analysis (since the TS version of the compiler relied on
Babel's scope analysis), so for now we require that the scope data be
serialized. It's a denormalized graph, and some metadata has to be
stored to associate nodes with scopes. We're open to feedback about the
AST and scope representation - we iterated a bit just to get things to
work, but it can be more optimal.

Key changes that we are considering:
* Currently the compiler returns `Option<Program>`, which is `Some` if
anything changed. This requires replacing the entire program. We plan to
change this to return a series of patches to apply, in a form that is
reasonably usable and efficient for all the integrations we care about
(Babel, OXC, SWC, etc).
* The Rust representation of the Babel AST is fine enough, but we could
make it more optimal by doing arena allocation. We also plan to change
the string representation to smol_str.
* The scope representation, and association of data btw AST and scope,
is very much a first pass approach that is good enough. We expect to
implement our own scope resolution, though, so we hopefully won't need
to iterate on the scope representation and can just throw it away.

In terms of the shape of the integration, we anticipate that each
integration would have the following:

* Implementor repo (OXC, SWC, etc): lightweight code transform and lint
pipeline integration that delegates to `crates/react_compiler_<name>`
from our repo
* Our repo: one crate per implementor, eg react_compiler_swc,
react_compiler_oxc, where most of the logic lives.

This setup lets us make changes to the integration layer easily within
our repo. Feedback appreciated!

---------

Co-authored-by: Joe Savona <[email protected]>
Co-authored-by: Mike Vitousek <[email protected]>
Co-authored-by: Mike Vitousek <[email protected]>
Co-authored-by: lauren <[email protected]>
Co-authored-by: lauren <[email protected]>
2026-06-09 14:53:06 -07:00

8.9 KiB

Rust port: e2e parity TODO

Status snapshot (after the current stack lands):

Variant Score Failures
Babel 1792 / 1802 10
SWC 1786 / 1802 16
OXC 1704 / 1795 91

The corpus grew by the three ts-* module-interop fixtures (1799 → 1802 on this branch). The Babel/SWC rows are measured on this branch and their failure sets are byte-identical to the pre-stack baseline; the OXC row predates the fixtures and has not been re-measured.

cargo test --workspace: 84 passed, 0 failed.

SWC

(Historical, pre-ts-interop-stack triage on the old staging base; current snapshot at top.)

The 15 remaining SWC e2e failures fall into three groups. Each line names the fixture and the failure mode; the group it sits in dictates the appropriate fix.

Group A: Fixture maintenance, not Rust bugs

SWC compiles code that TS rejects, or vice versa, in ways where Rust's behavior is arguably correct. The fix is to rename the fixture (drop the error. prefix) and update the .expect.md snapshot so the suite stops asserting the TS-specific output.

  • error.bug-invariant-local-or-context-references.js — TS fires CompilerError::invariant ("expected all references ... consistently local or context"). Rust handles the same code without tripping the invariant.
  • error.todo-jsx-intrinsic-tag-matches-local-binding.js — SWC pipeline emits a Todo bailout ([hoisting] EnterSSA: Expected identifier to be defined before being used) that the Babel path does not.
  • error.todo-repro-named-function-with-shadowed-local-same-name.js — Babel errors; SWC compiles.
  • new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js — same as above with the new mutation-aliasing model enabled.
  • error.todo-rust-as-expression-assignment-target.tsx — Babel errors; SWC compiles.
  • fbt/error.todo-locally-require-fbt.js — Babel emits the Invariant: <fbt> tags should be module-level imports shape; SWC emits Todo: Local variables named 'fbt' may conflict with the fbt plugin. Different categories, both reasonable.

Group B: External dependency

  • use-no-forget-multiple-with-eslint-suppression.js — spurious import { c as _c } in the TS reference output. Fixed on main by react#36500 (merged). Will pass automatically once pr-36173 rebases onto main; until then the TS dist built from pr-36173 still emits the unused import.

Group C: Real SWC frontend bugs

Each line names the failure mode and a sketch of where to look.

  • fbt/fbt-param-with-quotes.js — SWC codegen emits double quotes ("fbt") and reformats multi-line JSX into a single line; Babel uses single quotes and preserves the source layout. Semantically equivalent output; the fix is either an SWC codegen flag for quote style or a post-emit pass. Low impact, high effort.

  • lone-surrogate-string-values.js — TS preserves lone surrogates (\uD83E); SWC emits \uFFFD because Wtf8Atom::to_string_lossy() in react_compiler_swc/src/convert_ast.rs::wtf8_to_string replaces invalid UTF-8 sequences. Real WTF-8 handling work that touches every call site using that helper. Probably needs to detect lone surrogates and emit \uXXXX escapes before they hit String.

  • many-scopes-no-stack-overflow.js — TS memoizes the function (const $ = _c(401); with 401 memo slots); SWC pipeline bails out and returns the uncompiled source. The fixture exists to test that the compiler handles many sequential reactive scopes without stack overflow, so the SWC variant should compile. Root cause unclear — needs investigation in the SWC pipeline or the compiler core to see where the bail happens.

  • pattern4_bare_type.js — Two unrelated bugs in one fixture:

    1. Operator-precedence stripping. Math.round((x - y) * 1000) becomes Math.round(x - y * 1000). SWC codegen drops the parentheses around the subtraction. Probably in convert_ast_reverse.rs's BinaryExpression handling.
    2. Method return type annotation. formatMetrics(): Metrics becomes formatMetrics(). The TS-type-on-binding-ident fix in commit cc1ba1e1 only covered binding identifiers; class method signatures are a separate code path. Same shape of fix; different convert_binding_ident-equivalent call site.
  • reduce-reactive-deps/hoist-deps-diff-ssa-instance1.tsx — (x as HasA).a.value + 2 becomes (x as HasA.a.value) + 2. The member expression's property chain gets absorbed into the type annotation when convert_ast_reverse emits the cast. Likely a parenthesization / precedence bug in the reverse converter or the SWC printer's handling of TSAsExpression as the object of a MemberExpression.

  • todo-round2_unicode_string.js (prefixed todo-) — Hex escape format (\xC5) vs unicode escape (\u00C5) for bytes 0x80-0xFF. Both valid JS literals; codegen format choice in SWC's string printer.

  • todo-round3_promote_used_temps.js (prefixed todo-) — Class body codegen. TS emits the class with fields and constructor; SWC emits an empty class body and pulls fields/methods out into separate assignments. Likely an interaction between SWC codegen and the compiler's promote_used_temps pass.

  • ts-non-null-expression-default-value.tsx — Generic type parameter support. const x: ReadonlyMap<string, string> = ... becomes const x = ... (annotation dropped entirely). Our convert_ts_type_to_json helper in cc1ba1e1 explicitly guards against TsTypeRef with type_params to avoid silently emitting ReadonlyMap without the params. The proper fix needs serialization of TSTypeParameterInstantiation in convert_ast.rs AND deserialization in convert_ast_reverse.rs::convert_ts_type_from_json.

Cross-frontend: TypeScript module interop statements

Three ts-* fixtures pin how TS module-interop statements (import x = require(...), export = x, export as namespace X) must behave: the statement is preserved in output and the file's functions still compile.

  • Babel/NAPI and SWC now preserve these end to end. Both flow the statements through Statement::Unknown (the raw Babel-shaped carrier in react_compiler_ast); the SWC frontend rebuilds the swc module declarations in convert_ast_reverse.rs and works around an upstream swc_ecma_codegen bug that prints TsNamespaceExportDecl as export = X (react_compiler_swc/src/ts_namespace_export_fixup.rs, which also carries the guard test that flags when the upstream fix lands). Fixtures renamed from todo-ts-* to ts-*; the SproutTodoFilter entry for the namespace fixture remains (sprout's evaluator cannot evaluate export as namespace).

  • OXC remains deferred: todo!() panics in react_compiler_oxc/src/convert_ast.rs (arms TSImportEqualsDeclaration / TSExportAssignment / TSNamespaceExportDeclaration; the sibling TSGlobalDeclaration arm is also unmodeled but unreachable from Babel-parsed fixtures, which represent declare global as TSModuleDeclaration).

  • ts-import-equals-declaration.ts

  • ts-export-assignment.ts

  • ts-namespace-export-declaration.ts

Babel

(Historical, pre-ts-interop-stack numbers; current snapshot at top.)

TODO: scope this out. Babel is at 1788 / 1795 (7 failures). These have been the baseline throughout the SWC parity stack and were not touched, so the failure list is whatever was on pr-36173 before this work landed.

Next step is to enumerate the failures by fixture and bucket them the same way as SWC (fixture maintenance / external dependency / real bugs). Run:

bash compiler/scripts/test-e2e.sh --no-color --variant babel

…and triage the resulting failures into A/B/C groups under this section.

OXC

(Historical, pre-ts-interop-stack numbers; current snapshot at top.)

TODO: scope this out. OXC is at 1704 / 1795 (91 failures). The CLI filename fix in commit c30f0d6f bumped this by +2 from the 1702 baseline, but everything else is unaddressed.

Next step is to enumerate failures and identify OXC-specific clusters (likely AST conversion gaps in react_compiler_oxc analogous to the SWC work in this stack). Run:

bash compiler/scripts/test-e2e.sh --no-color --variant oxc

…and bucket the resulting failures into A/B/C groups under this section. Expect significant overlap with the SWC Group C bugs (cast wrappers, type annotations, UTF-16/WTF-8 handling) since both frontends share the post-conversion pipeline.

How this stack got here

(Historical, pre-ts-interop-stack numbers; current snapshot at top.)

  • compiler/scripts/test-e2e.sh --variant swc baseline was 1742 / 1795 (53 failures) before this stack.
  • 9 commits in the current stack reduce that to 1780 / 1795 (15 failures, -38 fixtures, 72% reduction).
  • Babel variant: 1788 / 1795 throughout (no regressions).
  • OXC variant: 1702 → 1704 (the CLI filename commit also benefited OXC).
  • cargo test --workspace: 56 passed, 0 failed throughout.