Skip to content

[compiler] Port React Compiler to Rust#36173

Open
josephsavona wants to merge 425 commits into
facebook:mainfrom
josephsavona:rust-research
Open

[compiler] Port React Compiler to Rust#36173
josephsavona wants to merge 425 commits into
facebook:mainfrom
josephsavona:rust-research

Conversation

@josephsavona
Copy link
Copy Markdown
Member

@josephsavona josephsavona commented Mar 30, 2026

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!

Joe Savona added 30 commits March 21, 2026 00:50
…esHIR — 1709/1717 passing (99.5%)

OutlineFunctions (6 fixes): Changed block rewrite to place replacement instructions
at the LAST JSX instruction's position (matching TS behavior where `state.jsx.at(0)`
is the last in forward order due to reverse iteration). Removed explicit JSX instruction
skipping; let DCE handle cleanup as the TS does.

MergeOverlappingReactiveScopesHIR (3 fixes): Changed scope deduplication in
`collect_scope_info` to preserve insertion order instead of sorting by ScopeId.
The TS uses `Set<ReactiveScope>` which preserves insertion order, and the order
determines which scope becomes the disjoint set root during union.
8 failures remaining: PSDH scope declarations (5), error reporting from
unported reactive passes (3). All OutlineFunctions, MergeOverlapping, and
validation passes now clean.
… (99.8%)

Two fixes:

1. Fix ProcessedInstr key collision between inner and outer functions.
   The ProcessedInstr::Instruction variant used EvaluationOrder (instruction
   ID) as its key, but instruction IDs are NOT unique across functions —
   each function has its own numbering. This caused inner function optional
   chain instructions to incorrectly mark outer function instructions as
   "deferred", skipping their dependency processing. Changed to use
   IdentifierId (lvalue identifier) which is globally unique across all
   functions in the arena.

2. Fix hoistable property load propagation through loops. The iterative
   intersection approach failed for cycles (loop backedges) because it
   would intersect with incomplete predecessor sets. Replaced with
   recursive DFS using 'active'/'done' state tracking (matching the TS
   implementation), which correctly filters out cycle nodes from the
   intersection, allowing non-null info to propagate through non-cyclic
   paths.
4 remaining failures are all blocked: 2 require PruneHoistedContexts (reactive pass),
1 RIKBR invariant error handling, 1 pipeline error handling difference.
All 31 HIR transformation passes and all validation passes are clean.
…ate, and BuildReactiveFunction

Create ReactiveFunction/ReactiveBlock/ReactiveValue types in react_compiler_hir.
Create react_compiler_reactive_scopes crate with BuildReactiveFunction pass and
debug printer. Wire BuildReactiveFunction into the pipeline after
PropagateScopeDependenciesHIR.
…s for reactive passes

Create TS verbose debug printer for ReactiveFunction matching the Rust format.
Export DebugPrinter class from DebugPrintHIR.ts. Update test-rust-port.ts to
handle kind: 'reactive' log entries using the new printer.
… mapping

Expand compiler-orchestrator pass table with entries facebook#32-facebook#49 for reactive passes.
Update compiler-port SKILL.md with reactive crate mapping and remove blocking
note. Add reactive pass patterns to port-pass agent.
…add panic catching

Fix bug where switch case blocks were always None after scheduling.
Fix loop unschedule to always remove block (matching TS behavior).
Add catch_unwind in pipeline to gracefully handle BuildReactiveFunction panics.
Update field ordering, type names, and formatting in print_reactive_function.rs
to match TS DebugPrintReactiveFunction output exactly. Removes index prefixes,
fixes terminal field ordering (id/loc at end), quotes targetKind, and matches
switch case block: undefined format.
…ned functions

Guard against outlined functions that haven't been converted to reactive form
yet (still have HIR body with blocks, not reactive array body).
…s in reactive printer

Add HirFunctionFormatter callback to reactive DebugPrinter so FunctionExpression
and ObjectMethod values can print their inner HIR functions with full detail.
Bridge debug_print.rs formatting into the reactive printer via format_hir_function_into.
Remove blank line output for unprinted outlined functions that caused
Environment section misalignment. 1285/1717 fixtures now pass.
…unction value blocks

Port the TS logic that converts StoreLocal to LoadLocal when the last instruction
of a value block stores to an unnamed temporary. This fixes identifier/place
mismatches in the reactive function output. 1459/1717 fixtures now pass.
In BuildReactiveFunction, for-loops should use the update block as the continue
target when present, falling back to the test block. Matches TS
terminal.update ?? terminal.test pattern.
BuildReactiveFunction is implemented with 1458/1717 fixtures passing (85%).
Major fixes to match the TypeScript BuildReactiveFunction behavior:

- Add valueBlockResultToSequence for for/for-of/for-in init and for-of test values,
  which wraps value block results in SequenceExpressions with proper lvalue assignment
- Fix for-of continue_block to use init (not test), matching TS scheduleLoop call
- Add reachable() checks for if, switch, while, and label terminal fallthroughs
- Add loopId checks for all loop types (do-while, while, for, for-of, for-in) to
  verify loop blocks aren't already scheduled before traversal
- Add alternate != fallthrough check for if terminals (matching TS branch semantics)
- Fix switch case processing order to reverse (matching TS reverse-iterate-then-reverse)
- Fix switch to skip already-scheduled cases instead of pushing None blocks
- Fix value block catch-all to not propagate parent fallthrough (TS passes null)
- Clean up dead code in value block catch-all

Pass rate: 1635/1717 (95.2%). Remaining 82 failures are all earlier-pass issues.
Ported 15 reactive passes and visitor/transform infrastructure from TypeScript to Rust.
Includes assertWellFormedBreakTargets, pruneUnusedLabels, assertScopeInstructionsWithinScopes,
pruneNonEscapingScopes, pruneNonReactiveDependencies, pruneUnusedScopes,
mergeReactiveScopesThatInvalidateTogether, pruneAlwaysInvalidatingScopes, propagateEarlyReturns,
pruneUnusedLValues, promoteUsedTemporaries, extractScopeDeclarationsFromDestructuring,
stabilizeBlockIds, renameVariables, and pruneHoistedContexts. 1603/1717 tests passing (93.4%).
…-port.ts

The .replace(/\(generated\)/g, '(none)') normalization was effectively a no-op:
both TS and Rust event items go through the same formatLoc in the test harness,
producing identical (generated) strings. The HIR debug printers output "generated"
without parentheses, so the regex never matched HIR output either.
Reorder the 4 create_temporary_place_id calls in apply_early_return_to_scope
to match the TypeScript allocation order (sentinelTemp first, then symbolTemp,
forTemp, argTemp). The Rust port had them in a different order, causing
IdentifierIds to be assigned differently and producing 33 test divergences
in PropagateEarlyReturns output.
…S behavior

In TypeScript, `buildReverseGraph` (Dominator.ts:237) calls `fn.env.nextBlockId`
to create a synthetic exit node, which increments the block ID counter as a
side-effect. The Rust port reads `env.next_block_id_counter` without incrementing.

This causes block ID offsets: for a simple function, TS allocates 3 extra block
IDs (one each from ValidateHooksUsage, ValidateNoSetStateInRender, and
InferReactivePlaces) that Rust doesn't, causing all subsequent block IDs to
differ by 3.

Fix by changing the 3 callers to use `env.next_block_id().0` instead of
`env.next_block_id_counter`, consuming the ID to match TS behavior. This
reduces block ID divergences from ~1505 to ~117 fixtures (remaining divergences
are from recursive dominator calls within inner function validation).
…ew docs

Aggregate top issues from ~95 per-file reviews into 20260321-summary.md.
Key findings: ~55 panic!() calls that should be Err(...), type inference
logic bugs, severely compressed validation passes, weakened SSA invariants,
and JS semantics divergences in ConstantPropagation. Removes stale
aggregated summary docs (SUMMARY.md, README.md, etc.) while keeping
per-file reviews.
…re guidelines

Corrected several recommendations that were inconsistent with rust-port-architecture.md:
removed "at minimum panic!()" as acceptable for invariants (must be Err), marked tryRecord
as unnecessary in Rust since Result handles the concern more cleanly, fixed incorrect
claim that obj.class is invalid JS, and clarified that invariant violations must propagate
via Err rather than accumulate on env.
…eps, names scope, unify shapes, phi/cycle errors

Fix 5 bugs in InferTypes:
- 2a: Resolve types for captured context variables in apply phase (FunctionExpression/ObjectMethod)
- 2b: Resolve types for StartMemoize deps with NamedLocal kind
- 2d: Merge unify/unify_with_shapes so shapes are always available for property resolution
- 3a: Return Err(CompilerDiagnostic) for empty phi operands and cycle detection instead of silent return
Also updated pipeline.rs to handle the new Result return type.

Note: Bug 2c (shared names map) was already correct — inner functions use a fresh HashMap.
…on-null assertion

Changed unwrap_or(0) to .expect() for unsealed_preds lookup. TS uses a
non-null assertion (!) which maps to unwrap/panic per the architecture guide.
Silently defaulting to 0 could produce incorrect SSA IDs.
…ThatInvalidateTogether

Changed 'while index <= entry.to.saturating_sub(1)' to 'while index < entry.to'
to match TS semantics. The old code would incorrectly process index 0 when
entry.to was 0 (saturating_sub(1) returns 0, and 0 <= 0 is true).
…and number formatting

- Added 'delete' and 'await' to is_reserved_word (6a)
- Changed integer overflow guard from n.abs() < 1e20 to n.abs() < (i64::MAX as f64)
  to prevent potential issues with large integers near the threshold (6c)
- js_to_number already handles empty/whitespace strings correctly (6b was already fixed)
…ompilationMode and PanicThreshold

Created CompilationMode (Infer/Annotation/All) and PanicThreshold
(AllErrors/CriticalErrors/None) enums with serde support. Updated all
string comparisons in program.rs to use enum pattern matching.
…tch TS non-null assertion"

This reverts commit e3c80a2.
@dyc3
Copy link
Copy Markdown

dyc3 commented Jun 3, 2026

Hello from Biome! Happy to see this moving along.

So far, the biggest points of friction for me have been:

  • converting Biome's CST into the shape the compiler expects (though, not sure there's much you can do about that)
  • Errors are emitted as strings instead of something more strongly typed. We have preferences as to how errors are conveyed to users. In order to classify the errors, we have to do string comparisons.
    • Specifically, if CompilerErrorDetailInfo's category just exposed ErrorCategory instead of rendering a string first, that would be a great first step. (It could also use some docstrings)
    • If reason could also be more strongly typed, that would also help.

Also, is it part of the plan to publish these crates to crates.io?

Boshen added a commit to oxc-project/oxc that referenced this pull request Jun 4, 2026
Native oxc integration for the Rust port of React Compiler (facebook/react#36173).

Vendors the oxc<->react_compiler_ast conversion layer (convert_ast, convert_ast_reverse, convert_scope, apply_renames, prefilter, diagnostics) and consumes the frontend-agnostic React Compiler core crates as git dependencies. Excluded from the workspace until the 0.121->0.134 API port lands and the core crates are published; build standalone via --manifest-path. See PLAN.md.
@kdy1
Copy link
Copy Markdown

kdy1 commented Jun 5, 2026

Thank you so much! Nice work!

@rickhanlonii Can you publish the crates? I'm the creator of the SWC project, and I want to integrate it into SWC repository so users can test, but due to the Rust-side ecosystem, we cannot have git dependencies because crates.io blocks all crates with non-published dependencies.

…stExpression support

Add the missing `predicate` field to `FunctionExpression` and `ObjectMethod`
AST structs, matching `FunctionDeclaration` and `ArrowFunctionExpression`
which already had it. This fixes 5 round-trip test failures where Flow
`predicate` annotations were dropped during deserialization/serialization.

Add `TypeCastExpression` variant to the `PatternLike` enum for Flow type
cast expressions in pattern positions (e.g. `(x: Type) = value`). Handle
the new variant in all exhaustive match sites across the compiler: visitor,
build_hir, find_context_identifiers, program, validate_source_locations,
and the OXC/SWC converter modules.

Update test-babel-ast.sh to use `$CARGO` env var instead of hardcoded
`~/.cargo/bin/cargo` path.
@cpojer
Copy link
Copy Markdown
Contributor

cpojer commented Jun 5, 2026

@kdy1 This is exactly what we have done temporarily for testing in Oxc/Rolldown, you might be able to use those to start: https://github.com/oxc-project/forked-react-compiler

mvitousek added 6 commits June 5, 2026 14:45
…pr fixtures

Run prettier on all unformatted JS/TS fixture and source files.
Add Flow match expression fixtures to .prettierignore since prettier
can't parse the non-standard match syntax.
…TypeCastExpression in scope test

Add a known_failures list to the round_trip test to skip 5 fixtures with
unfixable issues: lone-surrogate string values (serde_json limitation) and
predicate: null round-trip loss (serde Option<T> semantics).

Add TypeCastExpression handling to the scope_resolution test's pattern
visitor to fix a compilation error.
Add skip_serializing_if to nodeIdToScope, nodeToScopeEnd, and refNodeIdToBinding
so empty maps don't appear in serialized output, matching fixture expectations.
…t fixtures

Update babel-ast-to-json.mjs to assign _nodeId to all Identifier and
JSXIdentifier nodes during scope collection, matching the node-ID
assignment pattern from scope.ts's getOrAssignNodeId. Build
refNodeIdToBinding, nodeIdToScope, nodeToScopeEnd, and declarationNodeId
in the fixture scope info so the Rust-side rename_id function can resolve
identifiers by node ID.

Move AST serialization after scope collection so _nodeId fields appear
in the fixture JSON. Only emit new scope fields when non-empty to match
the Rust skip_serializing_if behavior.

Add known_failures skip list to scope_resolution_rename test for the
same 5 fixtures with pre-existing issues (lone surrogates, predicate null).
…tch-expr fixtures

Update 45 fixture snapshots that diverged from the expected output.
Rename 6 match-expression fixtures to error.todo- prefix since the npm
hermes-parser doesn't support Flow match syntax (only Meta's internal
Hermes does). This makes the TS test runner expect parse failures for
these fixtures.

1800/1800 tests pass.
… non-draining outlined function access

Two fixes for the JSX outlining bug where outlined functions lost access to
captured variables (x is not defined):

1. In build_outlined_scope_info (pipeline.rs): set base.node_id on all
   Identifier and JSXIdentifier nodes alongside base.start. The HIR lowering
   uses node_id (not position) to resolve identifier bindings via
   resolve_reference_for_node(). Without node_id, all identifiers in outlined
   functions were resolved as globals, causing the destructured props (i, x)
   to be treated as free variables instead of component parameters.

2. In codegen_reactive_function.rs: change take_outlined_functions() to
   get_outlined_functions().to_vec() (non-draining clone). This matches the
   TS behavior where getOutlinedFunctions() returns a reference, allowing
   both the inner function codegen and the parent/top-level codegen to
   process the same outlined functions.

Fixes all 10 jsx-outlining test failures. Rust plugin tests: 1784/1800 pass
(was 1775/1800).
@ax-openai
Copy link
Copy Markdown

Awesome, thank you so much ❤️ I've been waiting for this.

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.

Did you use Codex? I would love to write about this!

… to Rust output

Rename 7 error.todo-/error.bug- fixtures to drop the error. prefix since
the Rust compiler handles them correctly. Replace the unresolvable
import('./bar') in bug-invariant-local-or-context-references with an
inline mock so the evaluator can load the module.

Add TS_SKIP_FIXTURES (16 entries) to the snap reporter. Skipped only in
TS mode (!rust). Snapshots reflect Rust compiler output (source of truth).

Both test suites: 1800/1800 pass, 0 failures.
…in original_node

Fix UnsupportedNode codegen to properly handle both Statement and
Expression nodes in original_node:

- Statement nodes: emit directly (early return)
- Expression nodes: fall through to the expression codegen path which
  handles lvalue binding and temporary registration, matching the TS
  codegen's `if (!t.isExpression(node)) return node; value = node` pattern

Previously, expression nodes were wrapped in ExpressionStatement and
returned early, which broke temporary value lookup for downstream
instructions that reference the UnsupportedNode's lvalue.

This fixes 10 error fixtures (7 deser failures + 3 "No value found for
temporary" errors). Reduces TS_SKIP_FIXTURES from 16 to 9.

Both test suites: 1800/1800 pass, 0 failures.
poteto and others added 7 commits June 6, 2026 18:03
…nodes

The Babel parser emits `"predicate": null` on function-like nodes
(FunctionDeclaration, ArrowFunctionExpression, FunctionExpression,
ObjectMethod, DeclareFunction) to signal "no Flow `%checks`
predicate". Plain `Option` deserialization collapses both "absent"
and "explicit null" into `None`, and `skip_serializing_if =
"Option::is_none"` then drops the field on the way back out, so
round-tripped JSON loses the field and byte-equivalent comparison
fails.

Apply the existing `crate::common::nullable_value` deserializer to
all five predicate fields (ported from pr-36173 commit 8b880f2,
adapted: this branch already had plain `predicate` fields on all
function-like nodes, so only the attribute form changes). The helper
distinguishes:

- absent  -> None (skip on serialize)
- null    -> Some(Value::Null) (round-trips as `"predicate": null`)
- object  -> Some(Value::Object(_)) (round-trips a populated predicate)

This makes the predicate-related round_trip known_failures entries
unnecessary; remove them and keep only lone-surrogate-string-values
(serde_json rejects lone surrogates at parse time, unrelated to
predicate). Each removal verified against the full fixtures dir:

- component-in-object-method-body.flow: passes
- error.todo-round2_severity_diff: passes
- error.todo-update-expression-context-variable-via-type-annotation: passes
- error.todo-hoist-type-alias-before-declaration: entry was already
  dead (fixture renamed to todo-hoist-type-alias-before-declaration,
  the contains() check no longer matched) and the renamed fixture was
  failing both round_trip and scope_resolution_rename at baseline;
  passes now.

Test plan:
- bash compiler/scripts/test-babel-ast.sh:
    round_trip:  1782/1782 (baseline: 1778/1779, 1 failure)
    scope info round-trip: 1783/1783; rename: 1767/1767 (12 skipped)
    (baseline rename: 1 of 1767 failed on the same fixture)
- cargo test --workspace: all green (33 suites, 0 failures)
Replace the "rename to error.todo-*" approach for the six Flow `match`
fixtures with actual support, ported from pr-36173 commits 0dc7f2e
and d8aae6b. npm hermes-parser CAN parse match syntax: it requires
hermes-parser >= 0.28 plus the `enableExperimentalFlowMatchSyntax`
parser option (snap pinned 0.25.1 and never passed the flag; 0.26
carries an incompatible draft grammar).

- Un-rename the six match fixtures from error.todo-* back to their
  original names, restoring the pre-rename inputs (hermes-canonical
  formatting) and their real compiled snapshots:
    match-expr-captured-var.flow.js
    match-expr-jsx-spread.flow.js
    match-expr-multi-gen-bindings.flow.js
    match-expr-outlined-jsx.flow.js
    match-expression-with-tuple-and-early-return.js
    match-stmt-self-ref-const.flow.js
  All six pass against the checked-in snapshots with no regeneration
  (yarn snap -p 'match-*': 6 Tests, 6 Passed, 0 Failed).
- snap: hermes-parser ^0.28.0 in snap/package.json (+compiler
  yarn.lock; babel-plugin-syntax-hermes-parser stays 0.25.1 since
  nothing in snap's pipeline re-parses match syntax), pass
  enableExperimentalFlowMatchSyntax in parseInput, add the option to
  the hermes-parser module type in types.d.ts. compiler/yarn.lock also
  gains the previously-missing typescript entry for the
  babel-plugin-react-compiler-rust workspace (pre-existing drift that
  any yarn install regenerates).
- method-call-scope-merge-mutable-range-sync: rename tr/td to
  div/span (valid DOM nesting). The bare <tr> in sprout's container
  triggered a validateDOMNesting warning in exactly one of the two
  evaluations (warning dedup shares process state), so logs differed
  while rendered output was identical; this was the 1 failing test at
  baseline. Compiled shape unchanged; snapshot diff is tag literals
  only.
- prettier: format the match fixtures for real instead of ignoring
  them. Remove the .prettierignore match-* globs (stale since the
  error.todo-* rename: they no longer matched any file, which is why
  the prettier check failed at baseline with 6 parse errors). Add a
  .prettierrc.js override scoped to the match-* fixture paths using
  prettier-plugin-hermes-parser (root devDep at ^0.32.0 + root
  yarn.lock), whose parser handles the experimental syntax.
- TS_SKIP_FIXTURES: no entries removed; the current list (9 entries)
  contains no match-related fixtures. The match fixtures were handled
  entirely by the error.todo-* rename, which this commit reverts. The
  snapshots-reflect-Rust-output semantics and skip machinery are kept
  as-is.

Test plan:
- yarn snap: 1800 Tests, 1800 Passed, 0 Failed
  (baseline: 1800 Tests, 1799 Passed, 1 Failed)
- yarn snap -p 'match-*' -v: 6 Tests, 6 Passed, 0 Failed
- node scripts/prettier/index.js: exit 0 (baseline: exit 1 with parse
  errors on the six match fixtures)
- bash compiler/scripts/test-babel-ast.sh: parse 1771/1799 (28 parse
  errors, unchanged: @babel/parser cannot parse match syntax so those
  fixtures remain excluded from the round-trip corpus, exactly as
  before the un-rename); round_trip 1782/1782; scope info 1783/1783;
  rename 1767/1767 (12 skipped); exit 0
Pin how TSImportEqualsDeclaration, TSExportAssignment, and
TSNamespaceExportDeclaration must behave: the statement is preserved in
output and the file's functions still compile, as the TS reference
already does. The three frontends share the broken symptom today via
three different root causes: the Babel/NAPI path throws "Failed to
parse AST JSON: unknown variant ..." (the typed AST's tagged enums have
no catch-all) and fails the whole file; the SWC converter explicitly
rewrites the statements to EmptyStatement, erasing them from output
with no error and no event; OXC todo!()-panics in its converter
(deferred).

The fixtures use the bare todo- prefix rather than error.*: snap
asserts error.* fixtures throw on the TS side, and these compile
cleanly there. All three function bodies allocate so the compiled
snapshots visibly memoize; combined with the e2e events comparison,
a degenerate whole-file bailout cannot pass them.

Known-red until the fix slices land: Babel and SWC e2e on these three
fixtures, and test-babel-ast.sh (both round_trip and
scope_resolution_rename deserialize the same fixture JSON). TS-side
snap is green. SproutTodoFilter skips only the namespace fixture:
export as namespace is .d.ts-shaped and sprout's evaluator transform
cannot process it; the other two transform to CJS and evaluate fine.
Babel can emit statement kinds the typed AST does not model (the
todo-ts-* fixtures pin three TS module-interop forms). Deserialization
previously failed the whole file on the first such node, while the TS
reference compiles the file and leaves the statement alone.

Statement gains a final #[serde(untagged)] Unknown(UnknownStatement)
variant carrying the complete raw node. Deserialization is hand-written
and dispatches modeled `type` tags through a KnownStatement helper so a
malformed modeled node still errors with its precise field-level
message instead of degrading to Unknown; only genuinely unmodeled tags
take the catch-all. The TS reference reaches its equivalent default
case only via assertExhaustive (Babel's closed types), so it crashes;
here unmodeled syntax is reachable by construction and degrades
instead: top-level statements are preserved verbatim through
re-serialization, and function-body occurrences record the standard
UnsupportedSyntax bailout with an UnsupportedNode instruction carrying
the raw node. A known_statements! macro is the single source for the
dispatch enum, its From mapping, and the tag list, so those three
cannot drift; a variant added to Statement but not the macro is the one
remaining silent gap, documented on the variant.

UnknownStatement caches BaseNode for position helpers; the scoped
with_raw_mut mutator refreshes the cache and rejects mutations that
strip `type`, so the two views cannot desync. Program-level analyses
treat Unknown explicitly: the gating reference-before-declaration scan
walks the raw node for identifier references (an `export = X` does
reference X), and the prefilter and return-analysis arms are
deliberately inert. SWC/OXC reverse converters emit a deliberate
runtime tripwire (a throw in generated code) for the arms that are
unreachable until the SWC forward conversion stops rewriting these
statements to EmptyStatement in the next slice.

Deserialization now materializes a serde_json::Value per statement
before typed parsing. The cost is one move-based tree rebuild per
nesting level at a one-time boundary; the previous derive also buffered
every node through serde's internal Content to read the tag, so the
delta is allocation shape, not asymptotics.

Verified: ast unit tests including malformed/edge cases, a lowering
integration test pinning the function-body bailout, round_trip green on
the three fixtures, scoped and full Babel e2e green on all three with
events parity, cargo test --workspace green. The scope-resolution half
of test-babel-ast.sh is green on this stack's base and remains red
corpus-wide on the pr-36173 tip, whose node-ID migration removed
position-based keying while babel-ast-to-json.mjs still emits
offset-based scope JSON; that generator gap needs its own fix before
this stack rebases onto the tip. rust-port-0001-babel-ast.md's no-catch-all policy is
amended to document Statement as the deliberate exception.

Port adaptation for this branch's UnsupportedNode codegen fix
(0957b55), which discriminated statement-vs-expression
original_node by attempting a Statement deserialization. With the
tolerant deserializer that attempt succeeds for every tagged object,
which would silently emit expression nodes as raw statements and
orphan their lvalue temporaries — regressing the ~10 fixtures that
commit fixed. The codegen site now discriminates explicitly
(codegen_unsupported_original_node): modeled statement tags parse
typed and a parse failure is an invariant, not a degrade; tags that
parse as Expression or PatternLike (both strict enums, no catch-all)
flow through expression codegen unchanged, preserving the lvalue
binding and the pattern placeholder fallback; only genuinely unmodeled
tags — producible solely by the unknown-statement lowering bailout,
i.e. from statement position — degrade to Statement::Unknown and are
emitted verbatim, matching TS codegen's 'return node'.
is_known_statement_type is now exposed (pub) from the
known_statements! macro for this, and unit tests pin the
dispatch (modeled statement tag, malformed modeled tag, expression
tag, pattern tag, unknown tag).
…tend

The SWC converter rewrote TSImportEqualsDeclaration, TSExportAssignment,
and TSNamespaceExportDeclaration to EmptyStatement, silently deleting
them from output with no error and no event. Route them through the
same Statement::Unknown carrier the Babel path uses: the forward
converter builds the Babel-compatible raw node (field names and nesting
match @babel/parser; importKind/isExport carried; qualified
TSQualifiedName refs supported), and the reverse converter rebuilds
real swc module declarations at the ModuleItem layer, deserializing
sub-fields through the typed AST and reusing the existing expression
conversion. Malformed raw shapes, including invalid importKind or
isExport types, return None and hit the loud Stmt-level tripwire
rather than degrading.

swc_ecma_codegen v24 misprints TsNamespaceExportDecl as the
TsExportAssignment shape ("export = Foo"); the bug is also on swc
master and a genuine export assignment prints byte-identical text, so
the affected lines cannot be identified from output text alone. The
ts_namespace_export_fixup module anchors the rewrite on the source map
the emitter records: candidates are positions within the declaration's
span (the v24 emitter records only the identifier's span.lo, pinned by
a test), filtered by content verification because compiler-generated
imports carry synthetic spans that collide with a first-statement
declaration's span.lo. Unlocatable declarations panic; the silent
alternative emits a semantically different statement. A guard test
asserts raw swc still misprints the node, so an swc upgrade that fixes
the bug fails the test and prompts deleting the module.

The fixtures drop their todo- prefix (ts-import-equals-declaration,
ts-export-assignment, ts-namespace-export-declaration) now that both
Babel and SWC are green; the SproutTodoFilter entry follows the rename
(sprout's TS->CJS transform still cannot process export-as-namespace).
OXC remains deferred and documented.

Verified: react_compiler_swc 51 tests green including round trips for
import type / export import forms, UMD pairs, decoy template-literal
and block-comment lines, and the synthetic-span collision; workspace 78
green; all three fixtures pass scoped e2e on both variants including
events parity; full swc 1783/1798 and babel 1791/1798 with failure
lists identical to the documented baselines.

Port note: re-measured on this branch (lauren/port-rust-research,
fork corpus 1799 -> 1802 with the three fixtures): cargo workspace 84
green; yarn snap 1803/1803; full e2e babel 1792/1802 and swc 1786/1802
with failure sets byte-identical to the pre-stack baseline at
2aa3f0c (10 babel / 16 swc, none involving the ts-* fixtures).
TODO.md's status snapshot is updated to these measured numbers and the
inherited SWC triage section is marked historical like its siblings.
…default param (facebook#36107)

Cherry-picked from main (808e7ed) so the merge-ref CI on PR facebook#36173
stops failing on a fixture whose fix the branch lacked. The snapshot is
regenerated against this branch's compiler output: the diagnostic
wording here differs from main's, and snap snapshots record current
behavior. The wording delta reconciles when the branch merges to main.
CompileError logger events carry plain-object details (normalized for
Rust/TS logger parity), but the playground pushed event.detail straight
into CompilerError.details. Printing the error then crashed with
"detail.printErrorMessage is not a function", leaving the Next.js error
overlay up so Monaco never loaded and the source-syntax-error e2e test
timed out on every retry. Reconstruct CompilerDiagnostic /
CompilerErrorDetail instances at the logEvent boundary so downstream
consumers keep their method-based API.
Boshen added a commit to oxc-project/oxc that referenced this pull request Jun 7, 2026
Native oxc integration for the Rust port of React Compiler (facebook/react#36173).

Vendors the oxc<->react_compiler_ast conversion layer (convert_ast, convert_ast_reverse, convert_scope, apply_renames, prefilter, diagnostics) and consumes the frontend-agnostic React Compiler core crates as git dependencies. Excluded from the workspace until the 0.121->0.134 API port lands and the core crates are published; build standalone via --manifest-path. See PLAN.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.