[compiler] Port React Compiler to Rust by josephsavona · Pull Request #36173 · react/react

12 min read Original article ↗

added 30 commits

March 21, 2026 11:17
…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.
…al correspondence with TS
…reassigned for structural correspondence
…tch TS non-null assertion"

This reverts commit e3c80a2.
…ms for CompilationMode and PanicThreshold"

This reverts commit 88bf21f.
Mark completed items (2a-2d, 3a, 5b, 6a-6c, 7a-7c), note reverted items
(5c plugin enums broke serde, 8b enter_ssa fallback was correct), and
update remaining work items with findings from implementation.
… and consolidate pipeline error handling

Converted all CompilerError.invariant() and CompilerError.throwTodo() panics to
Err(CompilerDiagnostic) returns across 29 files, matching the architecture guide.
Added From<CompilerDiagnostic> for CompilerError impl to enable clean ? propagation,
replacing 17 verbose .map_err() blocks in pipeline.rs. Restored weakened SSA invariant
checks in rewrite_instruction_kinds_based_on_reassignment.rs.
…flatten(), convert remaining assert! calls

Replaced .ok().flatten() with ? in callers that return Result to properly
propagate invariant errors from environment shape resolution. Converted 10
remaining assert!/assert_eq! calls in build_reactive_function.rs to
Err(CompilerDiagnostic) returns. Simplified lower_expression's function
lowering to use .expect() since the error path is unreachable.
… Compiler

Copies the full react_compiler_oxc crate. Includes OXC 0.121 AST conversion,
reverse conversion, scope handling, prefilter, and diagnostics.
… Compiler

Copies the full react_compiler_swc crate. Includes SWC AST conversion,
reverse conversion, scope handling, prefilter, diagnostics, and integration tests.
Copies codegen_reactive_function.rs (~2800 lines) from the prior working branch.
Converts ReactiveFunction tree back into Babel-compatible AST with memoization
(useMemoCache) wired in. Includes pruneHoistedContexts fix for inner functions.
Connects codegen_reactive_function to the compilation pipeline:
- Added codegen module and pub use to reactive_scopes lib.rs
- Added react_compiler_ast dependency to reactive_scopes Cargo.toml
- Updated pipeline.rs to call codegen_function after PruneHoistedContexts
- Mapped codegen results (memo stats, outlined functions) to CodegenFunction
- Fixed build_reactive_function calls to handle Result return type
Extend the Rust port test script to capture and compare the final JavaScript
code produced by each compiler's Babel plugin, in addition to the existing
debug log entry comparison. The code is formatted with prettier before diffing.
Results are reported separately with their own pass/fail counts and diff output.
Add react_compiler_e2e_cli binary crate for testing SWC and OXC frontends
via stdin/stdout, codegen helpers (emit functions) to both react_compiler_swc
and react_compiler_oxc, and a test-e2e.ts orchestrator that compares output
from all 3 Rust frontends (Babel/NAPI, SWC, OXC) against the TS baseline.
JSON.stringify maps NaN/Infinity/-Infinity to "null" in the debug HIR
printer, so the TS side of the rust-port comparison harness printed
Primitive { value: null } for folded 0/0 while the Rust printer emits
the faithful NaN/Infinity spellings (format_js_number). The lossy form
also can't be told apart from a genuine null primitive. Print non-finite
numbers via String(); fixes codegen-nan-infinity-as-identifiers at the
ConstantPropagation frontier in the e2e comparison (final codegen
already matched).
…cope

TS resolves a function declaration's id via Babel's getBinding starting
at the function's OWN scope, so a body-level local that shadows the
function's name receives the store while outer references resolve to the
hoisted binding. The resulting split store/load chain is a known TS
quirk these fixtures memorialize (uninitialized-value invariant). The
port had switched to node-id resolution (30f1ba7), which stored into
the outer binding and made the fixtures compile successfully, diverging
from TS in names, identifier numbering, and locs.

Restore the Babel-faithful scope walk as the primary resolution, with
rename-awareness (Babel scope.rename re-keys bindings, which is how
function-decl-shadowed-by-inner-const still resolves outward) and the
previous node-based path as fallback for backends with split
function-body scopes. The StoreContext/StoreLocal decision now derives
from the same resolved binding.

With parity restored, both compilers error identically on the three
fixtures, so they return to their pre-30f1ba7fd9 error.-prefixed names
(reverting the 4245fe2 renames) with snapshots regenerated from the
now-converged output.
…lerates context places

Two halves of one parity fix:

The rust babel plugin's scope serialization registered lowercase JSX tag
names matching a local binding only in the deprecated position-keyed
referenceToBinding map; route them through mapRef so they also land in
refNodeIdToBinding, the map the Rust side actually consumes. The Rust
capture analysis now sees e.g. <colgroup> resolving to a local const
colgroup, matching TS gatherCapturedContext.

That capture surfaces a latent bug shared by BOTH compilers: a
function's context places capture a binding, not a value, but EnterSSA
treated an entry-reaching context place as use-before-define and threw
the [hoisting] todo when the variable was declared later in the block
(const colgroup = useMemo(() => <colgroup>...) self-capture). Unmark
context-place identifiers from the unknown set in both EnterSSA
implementations; genuine reads-before-define inside the function body
re-mark via LoadLocal and still bail (error.dont-hoist-inline-reference
unchanged). The spurious context entry is pruned by AnalyseFunctions +
DCE, so final output is unchanged.

Fixes todo-jsx-intrinsic-tag-matches-local-binding on the e2e comparison
(both pass-by-pass and codegen), where Rust previously missed the
capture entirely.
Four TS_SKIP_FIXTURES entries are now vacuous: the three shadowed-own-
name fixtures error identically in both compilers (and were renamed back
to error.-prefixed names), and todo-jsx-intrinsic-tag-matches-local-
binding now compiles identically in both. The remaining entries are
genuinely divergent fixtures.

@poteto

…semantics

Four root causes, all in how the port approximated Babel/TS traversal:

1. Hoisting guard over-applied. The is_binding_in_block_direct_statements
   guard compensates for scope_bindings_with_children pulling in child
   block scopes, but it also rejected the block's OWN scope bindings.
   Babel attributes catch params and for-in/for-of head vars to the
   block's scope without any direct declaring statement (probe: the
   catch body's path.scope IS the CatchClause scope), and TS hoists
   them into DeclareContext. Guard now applies only to child-scope
   bindings. Fixes error.bug-context-variable-catch-in-lambda,
   error.bug-invariant-local-or-context-references (both now converge
   on TS's consistently-local-or-context invariant) and round2_loc_diff
   (a 10-file round-2 pattern).

2. Babel's scope crawl misses references its own isReferencedIdentifier
   classifies as referenced (observed: Flow FunctionTypeParam names
   resolving to value bindings are absent from binding.referencePaths
   under @babel/core's traverse, present under a bare re-traverse). TS's
   FindContextIdentifiers and hoisting re-traverse and so DO see them.
   scope.ts now maps crawl-missed referenced identifiers; the identifier
   loc index tracks in_type_annotation for them; gather_captured_context
   excludes annotation refs, matching TS's gatherCapturedContext which
   skips TypeAnnotation subtrees while FindContextIdentifiers does not.
   Fixes error.todo-update-expression-context-variable-via-type-annotation
   (StoreContext parity + the UpdateExpression-on-context todo) and
   todo-hir_identifier_diff (a 20-file pattern: React.Node annotation
   refs no longer captured into jest.mock factory contexts).

3. record_unsupported_lval recorded the TSAsExpression assignment-target
   todo and continued, so Rust logged HIR for functions TS never lowered
   (TS's handleAssignment default case throws immediately). It now
   returns Err. Fixes error.todo-rust-as-expression-assignment-target.

4. Hermes component-syntax desugar reuses source offsets, so a sibling
   reference (the forwardRef argument naming the desugared inner
   function) positionally aliases the function name it refers to and
   fell inside the function's capture range. Skip references whose
   offset equals their binding's declaration offset; impossible in real
   source, exact for desugared aliases. Fixes
   error.todo-round2_id_numbering (a 12-file round-2 pattern).

e2e comparison: Results 1801/1803, Code 1803/1803 (remaining two are
the parked fbt local-require and WTF-8 lone-surrogate items). Both snap
channels 1804/1804 with the companion fixture-rename commit.
…hots, skip list

With the hoisting parity fix, Rust errors identically to TS on the two
catch-param-captured-by-lambda fixtures, so they return to their
pre-4245fe23b9 error.bug- names with snapshots regenerated from the
now-converged output, and their TS_SKIP_FIXTURES entries are dropped
(three genuinely-divergent entries remain). Depends on the preceding
parity commit; snap --rust is 1804/1804 only with both applied.
…ed_names

has_local_binding() checked used_names, which is only populated as
identifiers are resolved during HIR lowering. JSX tag names bypass
normal identifier resolution, so when lowering <fbt>, the fbt binding
from `const fbt = require('fbt')` might not be in used_names yet.

Switch to scope_info.find_binding_in_descendants(), which searches
Babel's complete scope data for any binding with the given name in the
compiled function's scope tree. This matches TS behavior where
resolveIdentifier uses scope.getBinding().
…shots

- Bump snap's hermes-parser dependency from ^0.28.0 to ^0.32.0 to get
  enableExperimentalFlowMatchSyntax support for Flow match fixtures.
  Update yarn.lock to resolve ^0.32.0 to 0.32.0 with correct integrity.
  Yarn workspaces nests 0.32.0 in packages/snap/node_modules/ since
  babel-plugin-syntax-hermes-parser pins 0.25.1 at the workspace root.
- Regenerate 6 match-expr/match-stmt fixture snapshots (now parse and compile)
- Update method-call-scope-merge-mutable-range-sync snapshot
- ts-namespace-export-declaration was already in SproutTodoFilter

Both yarn snap --rust and yarn snap: 1804/1804, 0 failures.
Verified: rm -rf node_modules && yarn install resolves hermes-parser
0.32.0 for snap, tests pass from clean state.
… version

Update eval output from `(kind: exception) licensedGeos.toSorted is not a
function` to the actual rendered HTML. The exception was an artifact of
system Node 16 which lacks Array.prototype.toSorted(); CI uses Node 20+
where toSorted() works and the component renders successfully.
…l fixture

toSorted() is unavailable on Node 16 (system default), causing the eval
to throw instead of rendering. Replace with [...licensedGeos].sort()
which works on all Node versions. The test exercises scope merging and
mutable range sync, not Array.prototype.toSorted specifically.

mvitousek

@blka blka mentioned this pull request

Jun 10, 2026

This was referenced

Jun 10, 2026