Files
react/compiler/docs/rust-port/rust-port-0004-build-hir.md
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

35 KiB

Rust Port Step 4: BuildHIR / HIR Lowering

Goal

Port BuildHIR.ts (~4555 lines) and HIRBuilder.ts (~955 lines) into Rust equivalents in compiler/crates/react_compiler_lowering/. This is the first major compiler pass — it converts a Babel AST + scope info into the HIR control-flow graph representation.

The Rust port should be structurally as close to the TypeScript as possible: viewing the TS and Rust side by side, the logic should look, read, and feel similar while working naturally in Rust.

Current status: M1-M13 fully implemented. All statement types, expression types, destructuring, function expressions, JSX, switch/try-catch, for-of/in, optional chaining, and recursive lowering are complete. No todo!() stubs remain. cargo check passes. Remaining work: test against fixtures and fix divergences from TypeScript output.

Known issues to fix:

  • All collection types must use IndexMap/IndexSet (from the indexmap crate), not BTreeMap/BTreeSet/HashMap/HashSet. This is critical for HIR.blocks where BTreeMap destroys RPO insertion ordering.
  • Functions lower_function, lower_function_to_value, gather_captured_context, lower_object_property_key, lower_type take &Expression. The AST crate uses Expression for keys and doesn't have standalone Function/ObjectPropertyKey/TypeAnnotation types, so &Expression is correct for the current AST structure. When these functions are implemented, they should pattern-match on the specific expression variants internally.
  • VariableBinding::Identifier.binding_kind is String — must be a BindingKind enum.
  • HirBuilder is missing component_scope: ScopeId field (needed for gather_captured_context in M9).
  • build_temporary_place helper is missing (listed in M4).
  • mark_predecessors fallthrough handling: VERIFIED — matches TS eachTerminalSuccessor (does not include fallthroughs, correct).
  • GotoVariant::Break usage: VERIFIED — matches TS for both remove_unnecessary_try_catch and remove_dead_do_while_statements.

Crate Layout

compiler/crates/
  react_compiler_lowering/
    Cargo.toml
    src/
      lib.rs              # pub fn lower() entry point
      build_hir.rs        # lowerStatement, lowerExpression, lowerAssignment, etc.
      hir_builder.rs      # HIRBuilder struct
  react_compiler_hir/
    Cargo.toml
    src/
      lib.rs              # HIR types: HirFunction, BasicBlock, Instruction, Terminal, Place, etc.
      environment.rs      # Environment struct (arenas, counters, config)
  react_compiler_diagnostics/
    Cargo.toml
    src/
      lib.rs              # CompilerError, CompilerDiagnostic, ErrorCategory, etc.

Dependencies

# react_compiler_lowering/Cargo.toml
[dependencies]
react_compiler_ast = { path = "../react_compiler_ast" }
react_compiler_hir = { path = "../react_compiler_hir" }
react_compiler_diagnostics = { path = "../react_compiler_diagnostics" }

Key Design Decisions

1. No NodePath — Work Directly with AST Structs + ScopeInfo

The TypeScript lower() takes a NodePath<t.Function> and uses Babel's traversal API (path.get(), path.scope.getBinding(), etc.) extensively. The Rust port works with deserialized react_compiler_ast structs and the ScopeInfo from step 2.

TypeScript pattern:

function lowerStatement(builder: HIRBuilder, stmtPath: NodePath<t.Statement>) {
  switch (stmtPath.type) {
    case 'IfStatement': {
      const stmt = stmtPath as NodePath<t.IfStatement>;
      const test = lowerExpressionToTemporary(builder, stmt.get('test'));
      ...
    }
  }
}

Rust equivalent:

fn lower_statement(builder: &mut HirBuilder, stmt: &ast::Statement) {
    match stmt {
        ast::Statement::IfStatement(stmt) => {
            let test = lower_expression_to_temporary(builder, &stmt.test);
            ...
        }
    }
}

The mapping is direct: stmtPath.type switch becomes match stmt, stmt.get('test') becomes &stmt.test, type narrowing via as NodePath<T> becomes Rust's match arm binding.

2. Binding Resolution via ScopeInfo

The TypeScript resolveIdentifier() and resolveBinding() methods use Babel's scope API (path.scope.getBinding(), babelBinding.scope, babelBinding.path.isImportSpecifier(), etc.). The Rust port replaces all of this with ScopeInfo lookups.

TypeScript (HIRBuilder.resolveIdentifier()):

const babelBinding = path.scope.getBinding(originalName);
if (babelBinding === outerBinding) {
  if (path.isImportDefaultSpecifier()) { ... }
}
const resolvedBinding = this.resolveBinding(babelBinding.identifier);

Rust equivalent:

fn resolve_identifier(&mut self, name: &str, start_offset: u32) -> VariableBinding {
    // Look up via ScopeInfo instead of Babel's scope API
    let binding_id = self.scope_info.resolve_reference(start_offset);
    match binding_id {
        None => VariableBinding::Global { name: name.to_string() },
        Some(binding) => {
            if binding.scope == self.scope_info.program_scope {
                // Module-level binding — check import info
                match &binding.import {
                    Some(import) => match import.kind {
                        ImportBindingKind::Default => VariableBinding::ImportDefault { ... },
                        ImportBindingKind::Named => VariableBinding::ImportSpecifier { ... },
                        ImportBindingKind::Namespace => VariableBinding::ImportNamespace { ... },
                    },
                    None => VariableBinding::ModuleLocal { name: name.to_string() },
                }
            } else {
                let identifier = self.resolve_binding(name, binding_id.unwrap());
                VariableBinding::Identifier { identifier, binding_kind: BindingKind::from(&binding.kind) }
            }
        }
    }
}

Key differences:

  • resolveBinding() keying: TypeScript uses Babel node reference identity (mapping.node === node) to distinguish same-named variables in different scopes. Rust uses BindingId from ScopeInfo — the map becomes IndexMap<BindingId, IdentifierId> instead of Map<string, {node, identifier}>. This is simpler and more correct.
  • isContextIdentifier(): TypeScript checks env.isContextIdentifier(binding.identifier). Rust checks whether the binding's scope is an ancestor of the current function's scope but not the program scope — this is a ScopeInfo query.
  • gatherCapturedContext(): TypeScript traverses the function with Babel's traverser to find free variable references. Rust walks the AST directly using ScopeInfo.reference_to_binding to identify references that resolve to bindings in ancestor scopes.

3. HIRBuilder Struct

The HIRBuilder class maps to a Rust struct with &mut self methods. The closure-based APIs (enter(), loop(), label(), switch()) translate to methods that take impl FnOnce(&mut Self) -> T.

pub struct HirBuilder<'a> {
    completed: IndexMap<BlockId, BasicBlock>,
    current: WipBlock,
    entry: BlockId,
    scopes: Vec<Scope>,
    context: IndexMap<BindingId, Option<SourceLocation>>,
    bindings: IndexMap<BindingId, IdentifierId>,
    used_names: IndexMap<String, BindingId>,
    instruction_table: Vec<Instruction>,
    function_scope: ScopeId,
    component_scope: ScopeId,  // outermost component/hook scope, for gather_captured_context
    env: &'a mut Environment,
    scope_info: &'a ScopeInfo,
    exception_handler_stack: Vec<BlockId>,
    fbt_depth: u32,
}

Closure patterns: The TypeScript enter() method creates a new block, sets it as current, runs a closure, then restores the previous block. In Rust:

impl<'a> HirBuilder<'a> {
    fn enter(&mut self, kind: BlockKind, f: impl FnOnce(&mut Self, BlockId) -> Terminal) -> BlockId {
        let wip = self.reserve(kind);
        let wip_id = wip.id;
        self.enter_reserved(wip, |this| f(this, wip_id));
        wip_id
    }

    fn enter_reserved(&mut self, wip: WipBlock, f: impl FnOnce(&mut Self) -> Terminal) {
        let prev = std::mem::replace(&mut self.current, wip);
        let terminal = f(self);
        let completed = std::mem::replace(&mut self.current, prev);
        self.completed.insert(completed.id, BasicBlock {
            kind: completed.kind,
            id: completed.id,
            instructions: completed.instructions,
            terminal,
            preds: IndexSet::new(),
            phis: Vec::new(),
        });
    }

    fn loop_scope<T>(
        &mut self,
        label: Option<String>,
        continue_block: BlockId,
        break_block: BlockId,
        f: impl FnOnce(&mut Self) -> T,
    ) -> T {
        self.scopes.push(Scope::Loop { label, continue_block, break_block });
        let value = f(self);
        self.scopes.pop();
        value
    }
}

Variable capture across closures: TypeScript frequently assigns variables inside enter() closures that are read after:

let callee: Place | null = null;
builder.enter('block', () => {
  callee = lowerExpressionToTemporary(builder, ...);
  return { kind: 'goto', ... };
});
// callee is used here

In Rust, this pattern is handled by returning values from the closure:

let (block_id, callee) = {
    let block_id = builder.enter('block', |builder, _block_id| {
        // We can't easily return extra values from enter() since it expects Terminal
        // Instead, compute callee before/after enter(), or restructure
        ...
    });
    // Alternative: compute the value and store it on builder temporarily
};

For cases where this is awkward, use a temporary field on the builder or restructure the code to compute the value outside the closure. The specific approach depends on the case — see the incremental implementation milestones for details.

4. Source Locations

TypeScript accesses node.loc directly. Rust accesses node.base.loc (through the BaseNode flattened into each AST struct). Helper:

fn loc_from_node(base: &BaseNode) -> SourceLocation {
    base.loc.as_ref().map(|l| hir::SourceLocation::from(l)).unwrap_or(GENERATED_SOURCE)
}

5. Error Handling

Following the port notes:

  • CompilerError.invariant(cond, ...) → if !cond { panic!(...) } or dedicated compiler_invariant! macro
  • CompilerError.throwTodo(...) → return Err(CompilerDiagnostic::todo(...))
  • builder.recordError(...) → builder.record_error(...) (accumulates on Environment)
  • Non-null assertions (!) → .unwrap() or .expect("...")

The lower() function returns Result<HirFunction, CompilerError> for invariant/thrown errors, while accumulated errors go to env.errors.

6. todo!() Strategy for Incremental Implementation

BuildHIR is too large (4555 lines) for a single implementation pass. Use Rust's todo!() macro to stub unimplemented branches:

fn lower_statement(builder: &mut HirBuilder, stmt: &ast::Statement) {
    match stmt {
        ast::Statement::IfStatement(s) => lower_if_statement(builder, s),
        ast::Statement::ReturnStatement(s) => lower_return_statement(builder, s),
        ast::Statement::BlockStatement(s) => lower_block_statement(builder, s),
        // Stubbed — will be filled in later milestones
        ast::Statement::ForStatement(_) => todo!("lower ForStatement"),
        ast::Statement::WhileStatement(_) => todo!("lower WhileStatement"),
        ast::Statement::SwitchStatement(_) => todo!("lower SwitchStatement"),
        ast::Statement::TryStatement(_) => todo!("lower TryStatement"),
        // ... etc
    }
}

This "fog of war" approach allows:

  1. The code to compile at every step
  2. Tests to run for fixtures that only use implemented features
  3. Clear visibility into what remains
  4. Agents to pick up individual todo!() arms and implement them

Structural Mapping: TypeScript → Rust

Top-Level Functions

TypeScript (BuildHIR.ts) Rust (build_hir.rs) Notes
lower(func, env, bindings, capturedRefs) pub fn lower(ast: &ast::File, scope_info: &ScopeInfo, env: &mut Environment) -> Result<HirFunction, CompilerError> Entry point. Takes the full File (extracts the function internally)
lowerStatement(builder, stmtPath, label) fn lower_statement(builder: &mut HirBuilder, stmt: &ast::Statement, label: Option<&str>) ~30 match arms
lowerExpression(builder, exprPath) fn lower_expression(builder: &mut HirBuilder, expr: &ast::Expression) -> InstructionValue ~40 match arms
lowerExpressionToTemporary(builder, exprPath) fn lower_expression_to_temporary(builder: &mut HirBuilder, expr: &ast::Expression) -> Place
lowerValueToTemporary(builder, value) fn lower_value_to_temporary(builder: &mut HirBuilder, value: InstructionValue) -> Place
lowerAssignment(builder, loc, kind, target, value, assignmentStyle) fn lower_assignment(builder: &mut HirBuilder, ...) Handles destructuring patterns
lowerIdentifier(builder, exprPath) fn lower_identifier(builder: &mut HirBuilder, name: &str, start: u32, loc: SourceLocation) -> Place
lowerMemberExpression(builder, exprPath) fn lower_member_expression(builder: &mut HirBuilder, expr: &ast::MemberExpression) -> InstructionValue
lowerOptionalMemberExpression(builder, exprPath) fn lower_optional_member_expression(builder: &mut HirBuilder, expr: &ast::OptionalMemberExpression) -> InstructionValue
lowerOptionalCallExpression(builder, exprPath) fn lower_optional_call_expression(builder: &mut HirBuilder, expr: &ast::OptionalCallExpression) -> InstructionValue
lowerArguments(builder, args, isDev) fn lower_arguments(builder: &mut HirBuilder, args: &[ast::Expression], is_dev: bool) -> Vec<PlaceOrSpread>
lowerFunctionToValue(builder, expr) fn lower_function_to_value(builder: &mut HirBuilder, expr: &ast::Function) -> InstructionValue
lowerFunction(builder, expr) fn lower_function(builder: &mut HirBuilder, expr: &ast::Function) -> LoweredFunction Recursive lower() call. Returns LoweredFunction (not FunctionId)
lowerJsxElementName(builder, name) fn lower_jsx_element_name(builder: &mut HirBuilder, name: &ast::JSXElementName) -> JsxTag
lowerJsxElement(builder, child) fn lower_jsx_element(builder: &mut HirBuilder, child: &ast::JSXChild) -> Option<Place>
lowerObjectMethod(builder, property) fn lower_object_method(builder: &mut HirBuilder, method: &ast::ObjectMethod) -> ObjectProperty
lowerObjectPropertyKey(builder, key) fn lower_object_property_key(builder: &mut HirBuilder, key: &ast::ObjectPropertyKey) -> ObjectPropertyKey
lowerReorderableExpression(builder, expr) fn lower_reorderable_expression(builder: &mut HirBuilder, expr: &ast::Expression) -> Place
isReorderableExpression(builder, expr) fn is_reorderable_expression(builder: &HirBuilder, expr: &ast::Expression) -> bool
lowerType(node) fn lower_type(node: &ast::TypeAnnotation) -> Type
gatherCapturedContext(fn, componentScope) fn gather_captured_context(func: &ast::Function, scope_info: &ScopeInfo, parent_scope: ScopeId) -> IndexMap<BindingId, Option<SourceLocation>> AST walk replaces Babel traverser
captureScopes({from, to}) fn capture_scopes(scope_info: &ScopeInfo, from: ScopeId, to: ScopeId) -> IndexSet<ScopeId>

HIRBuilder Methods

TypeScript (HIRBuilder.ts) Rust (hir_builder.rs) Notes
constructor(env, options?) HirBuilder::new(env, scope_info, function_scope, bindings, context, entry_block_kind)
push(instruction) builder.push(instruction)
terminate(terminal, nextBlockKind) builder.terminate(terminal, next_block_kind)
terminateWithContinuation(terminal, continuation) builder.terminate_with_continuation(terminal, continuation)
reserve(kind) builder.reserve(kind) Returns WipBlock
complete(block, terminal) builder.complete(block, terminal)
enter(kind, fn) builder.enter(kind, |b, id| { ... }) Closure takes &mut Self
enterReserved(wip, fn) builder.enter_reserved(wip, |b| { ... })
enterTryCatch(handler, fn) builder.enter_try_catch(handler, |b| { ... })
loop(label, continue, break, fn) builder.loop_scope(label, continue_block, break_block, |b| { ... })
label(label, break, fn) builder.label_scope(label, break_block, |b| { ... })
switch(label, break, fn) builder.switch_scope(label, break_block, |b| { ... })
lookupBreak(label) builder.lookup_break(label)
lookupContinue(label) builder.lookup_continue(label)
resolveIdentifier(path) builder.resolve_identifier(name, start_offset) Uses ScopeInfo
resolveBinding(node) builder.resolve_binding(name, binding_id) Keyed by BindingId
isContextIdentifier(path) builder.is_context_identifier(name, start_offset) Uses ScopeInfo
makeTemporary(loc) builder.make_temporary(loc)
build() builder.build() Returns (HIR, Vec<Instruction>) — the HIR plus the flat instruction table
recordError(error) builder.record_error(error)

Post-Build Helpers (HIRBuilder.ts)

These helper functions in HIRBuilder.ts run after build() and clean up the CFG:

TypeScript Rust Notes
getReversePostorderedBlocks(func) get_reverse_postordered_blocks(hir) RPO sort + unreachable removal
removeUnreachableForUpdates(fn) remove_unreachable_for_updates(hir)
removeDeadDoWhileStatements(func) remove_dead_do_while_statements(hir)
removeUnnecessaryTryCatch(fn) remove_unnecessary_try_catch(hir)
markInstructionIds(func) mark_instruction_ids(hir) Assigns EvaluationOrder
markPredecessors(func) mark_predecessors(hir) Must include fallthrough blocks — verify each_terminal_successor matches TS eachTerminalSuccessor
createTemporaryPlace(env, loc) create_temporary_place(env, loc)

Implementation notes for post-build helpers:

  • remove_unnecessary_try_catch and remove_dead_do_while_statements: Verify that the GotoVariant used when replacing terminals matches the TS equivalent. Currently uses GotoVariant::Break — confirm this is correct.
  • mark_predecessors: The each_terminal_successor function must visit fallthrough blocks for terminals like Try, not just direct successors. Compare against TS eachTerminalSuccessor behavior.

Statement Lowering: Match Arm Inventory

The lowerStatement function has ~30 match arms. Grouped by complexity:

Tier 1 — Trivial (1-10 lines each)

  • EmptyStatement — no-op
  • DebuggerStatement — single Debugger instruction
  • ExpressionStatement — delegate to lower_expression_to_temporary
  • BreakStatement — builder.lookup_break() + goto terminal
  • ContinueStatement — builder.lookup_continue() + goto terminal
  • ThrowStatement — lower expression + throw terminal

Tier 2 — Simple control flow (10-30 lines each)

  • ReturnStatement — lower expression + return terminal
  • BlockStatement — iterate body statements
  • IfStatement — reserve blocks, enter consequent/alternate, branch terminal
  • WhileStatement — test block + body block + loop scope
  • LabeledStatement — delegate with label, or create label scope

Tier 3 — Complex control flow (30-100 lines each)

  • ForStatement — init/test/update/body blocks, loop scope
  • ForOfStatement — iterator protocol (GetIterator, IteratorNext, etc.)
  • ForInStatement — similar to ForOf
  • DoWhileStatement — body-first loop
  • SwitchStatement — case discrimination with fall-through
  • TryStatement — try/catch/finally blocks with exception handler stack

Tier 4 — Variable declarations and assignments (30-80 lines)

  • VariableDeclaration — iterate declarators, handle destructuring
  • FunctionDeclaration — hoist function, lower body

Tier 5 — Pass-through / error (1-10 lines each)

  • TypeScript/Flow declarations — todo!() or skip
  • Import/Export declarations — error (shouldn't appear in function body)
  • WithStatement — error (unsupported)
  • ClassDeclaration — lower class expression
  • EnumDeclaration / TSEnumDeclaration — error

Expression Lowering: Match Arm Inventory

The lowerExpression function has ~40 match arms. Grouped by complexity:

Tier 1 — Literals and simple values (1-10 lines each)

  • NullLiteral, BooleanLiteral, NumericLiteral, StringLiteral — Primitive instruction
  • RegExpLiteral — RegExpLiteral instruction
  • Identifier — delegate to lower_identifier
  • MetaProperty — LoadGlobal for import.meta
  • TSNonNullExpression, TSInstantiationExpression — unwrap inner expression
  • TypeCastExpression, TSAsExpression, TSSatisfiesExpression — unwrap inner expression

Tier 2 — Operators (10-30 lines each)

  • BinaryExpression — lower operands + BinaryExpression instruction
  • UnaryExpression — lower operand + UnaryExpression instruction
  • UpdateExpression — read + increment + store (prefix vs postfix)
  • SequenceExpression — lower all expressions, return last

Tier 3 — Object/Array construction (20-50 lines each)

  • ObjectExpression — properties, spread, computed keys
  • ArrayExpression — elements with holes and spreads
  • TemplateLiteral — quasis + expressions
  • TaggedTemplateExpression — tag + template

Tier 4 — Calls and member access (20-50 lines each)

  • CallExpression — callee + arguments + CallExpression/MethodCall instruction
  • NewExpression — similar to CallExpression
  • MemberExpression — object + property + PropertyLoad/ComputedLoad
  • OptionalCallExpression — optional chain with test blocks
  • OptionalMemberExpression — optional chain with test blocks

Tier 5 — Control flow expressions (30-80 lines each)

  • ConditionalExpression — if-like CFG with value blocks
  • LogicalExpression — short-circuit evaluation with blocks
  • AssignmentExpression — delegates to lower_assignment (destructuring)

Tier 6 — Complex (50-150 lines each)

  • JSXElement — tag + props + children + fbt handling
  • JSXFragment — children only
  • ArrowFunctionExpression / FunctionExpression — recursive lower_function
  • AwaitExpression — lower value + await instruction

Assignment Lowering

lowerAssignment (~500 lines in BuildHIR.ts) handles destructuring and is the most complex single function after the statement/expression switches. It processes:

Match arms by target type:

  • Identifier — StoreLocal instruction (with const/let/reassign distinction)
  • MemberExpression — PropertyStore / ComputedStore instruction
  • ArrayPattern — emit Destructure with ArrayPattern containing items, holes, rest elements, and default values
  • ObjectPattern — emit Destructure with ObjectPattern containing properties, computed keys, rest elements, and default values
  • AssignmentPattern — default value handling: lower the default, emit a conditional assignment

Rust approach:

The destructuring patterns map directly — the AST struct fields (elements, properties, rest) correspond to the Babel API calls. The main difference is accessing nested patterns through struct fields instead of path.get().


Recursive Lowering for Nested Functions

lowerFunction() calls lower() recursively for function expressions, arrow functions, and object methods. Key considerations for Rust:

  1. Shared Environment: Parent and child share &mut Environment. This works because the recursive call completes before the parent continues.

  2. Shared Bindings: The parent's bindings map is passed to the child so inner functions can resolve references to outer variables. In Rust, this is &IndexMap<BindingId, IdentifierId> — the parent's bindings are cloned or borrowed by the child.

  3. Context gathering: gatherCapturedContext() walks the function's AST to find free variable references. In Rust, this walks the AST structs using ScopeInfo to identify references that resolve to bindings in ancestor scopes (between the function's scope and the component scope).

  4. Function arena storage: The returned HirFunction is stored in env.functions (the function arena) and referenced by FunctionId in the FunctionExpression instruction value.

fn lower_function(builder: &mut HirBuilder, func: &ast::Function) -> LoweredFunction {
    let captured_context = gather_captured_context(func, builder.scope_info, builder.component_scope);
    let lowered = lower(func, builder.scope_info, builder.env, Some(&builder.bindings), captured_context)?;
    lowered
}

Incremental Implementation Plan

M1: Scaffold + Infrastructure

Goal: Crate structure compiles, lower() entry point exists, returns todo!().

  1. Create compiler/crates/react_compiler_diagnostics/ with CompilerDiagnostic, CompilerError, ErrorCategory, CompilerErrorDetail, CompilerSuggestionOperation.

  2. Create compiler/crates/react_compiler_hir/ with core types:

    • ID newtypes: BlockId, IdentifierId, InstructionId (index into the flat instruction table), EvaluationOrder (sequential numbering assigned during markInstructionIds() — this was previously called InstructionId in the TypeScript compiler), DeclarationId, ScopeId, FunctionId, TypeId
    • HirFunction, HIR, BasicBlock, WipBlock, BlockKind
    • Instruction, InstructionValue (enum with all ~40 variants, each stubbed as todo!() for fields)
    • Terminal (enum with all variants)
    • Place, Identifier, MutableRange, SourceLocation
    • Effect, InstructionKind, GotoVariant, BindingKind (enum: Var, Let, Const, Param, Using, AwaitUsing, CatchParam, ImplicitConst)
    • Environment (counters, arenas, config, errors)
    • FloatValue(u64) — wrapper type for f64 values that need Eq/Hash (stores raw bits via f64::to_bits() for deterministic comparison)
  3. Create compiler/crates/react_compiler_lowering/ with:

    • hir_builder.rs: HirBuilder struct with all methods stubbed
    • build_hir.rs: lower_statement() and lower_expression() with all arms as todo!()
    • lib.rs: pub fn lower() that creates a builder and returns todo!()
  4. Verify: cargo check passes.

M2: HIRBuilder Core

Goal: HIRBuilder methods work — can create blocks, terminate them, build the CFG.

  1. Implement HirBuilder::new(), push(), terminate(), terminate_with_continuation(), reserve(), complete(), enter_reserved(), enter().

  2. Implement scope methods: loop_scope(), label_scope(), switch_scope(), lookup_break(), lookup_continue().

  3. Implement enter_try_catch(), resolve_throw_handler().

  4. Implement make_temporary(), record_error().

  5. Implement build() including the post-build passes:

    • get_reverse_postordered_blocks()
    • remove_unreachable_for_updates()
    • remove_dead_do_while_statements()
    • remove_unnecessary_try_catch()
    • mark_instruction_ids()
    • mark_predecessors()

M3: Binding Resolution

Goal: resolve_identifier() and resolve_binding() work with ScopeInfo.

  1. Implement resolve_binding() — maps BindingId to IdentifierId, creating new identifiers on first encounter. Uses IndexMap<BindingId, IdentifierId> instead of the TypeScript Map<string, {node, identifier}>.

  2. Implement resolve_identifier() — dispatches to Global, ImportDefault, ImportSpecifier, ImportNamespace, ModuleLocal, or Identifier based on ScopeInfo lookups.

  3. Implement is_context_identifier() — checks if a reference resolves to a binding in an ancestor scope.

  4. Implement gather_captured_context() — walks AST to find free variable references using ScopeInfo.

M4: lower() Entry Point + Basic Statements

Goal: Can lower simple functions with ReturnStatement, ExpressionStatement, BlockStatement, VariableDeclaration (simple, non-destructuring).

  1. Implement the lower() function body: parameter processing, body lowering, final return terminal, builder.build().

  2. Implement statement arms:

    • ReturnStatement
    • ExpressionStatement
    • BlockStatement
    • EmptyStatement
    • VariableDeclaration (simple let x = expr only, destructuring as todo!())
  3. Implement basic expression arms:

    • Identifier (via lower_identifier)
    • NullLiteral, BooleanLiteral, NumericLiteral, StringLiteral
    • BinaryExpression
    • UnaryExpression
  4. Implement helpers: lower_expression_to_temporary(), lower_value_to_temporary(), build_temporary_place().

  5. Test: Run test-rust-port.sh HIR on simple fixtures.

M5: Control Flow

Goal: Branches and loops work.

  1. IfStatement — consequent/alternate blocks, branch terminal
  2. WhileStatement — test/body blocks, loop scope
  3. ForStatement — init/test/update/body blocks
  4. DoWhileStatement — body-first loop pattern
  5. BreakStatement, ContinueStatement
  6. LabeledStatement

M6: Expressions — Calls and Members

Goal: Function calls and property access work.

  1. CallExpression — including method calls (callee is MemberExpression)
  2. NewExpression
  3. MemberExpression — PropertyLoad/ComputedLoad
  4. lower_arguments() — spread handling
  5. SequenceExpression

M7: Expressions — Short-circuit and Ternary

Goal: Control-flow expressions produce correct CFG.

  1. ConditionalExpression — if-like structure with value blocks
  2. LogicalExpression — short-circuit &&, ||, ??
  3. AssignmentExpression — simple identifier/member assignment (destructuring deferred)

M8: Expressions — Remaining

Goal: All expression types handled.

  1. ObjectExpression — properties, methods, computed, spread
  2. ArrayExpression — elements, holes, spreads
  3. TemplateLiteral, TaggedTemplateExpression
  4. UpdateExpression — prefix/postfix increment/decrement
  5. RegExpLiteral
  6. AwaitExpression
  7. TypeCastExpression, TSAsExpression, TSSatisfiesExpression, TSNonNullExpression, TSInstantiationExpression
  8. MetaProperty

M9: Function Expressions + Recursive Lowering

Goal: Nested functions work.

  1. ArrowFunctionExpression, FunctionExpression — call lower_function()
  2. lower_function() — recursive lower() with captured context
  3. gather_captured_context() — AST walk for free variables
  4. Function arena storage via FunctionId
  5. FunctionDeclaration statement — hoisted function lowering

M10: JSX

Goal: JSX elements and fragments lower correctly.

  1. JSXElement — tag, props, children, fbt handling
  2. JSXFragment — children
  3. lower_jsx_element_name() — identifier, member expression, builtin tag dispatch
  4. lower_jsx_element() — child lowering (text, expression, element, spread)
  5. lower_jsx_member_expression()
  6. trimJsxText() — whitespace normalization

M11: Destructuring + Complex Assignments

Goal: Full destructuring support.

  1. lower_assignment() for ArrayPattern — items, holes, rest, defaults
  2. lower_assignment() for ObjectPattern — properties, computed keys, rest, defaults
  3. lower_assignment() for AssignmentPattern — default values
  4. VariableDeclaration with destructuring patterns
  5. Param destructuring in lower() entry point

M12: Switch + Try/Catch + Remaining

Goal: All statement types handled, complete coverage.

  1. SwitchStatement — case discrimination, fall-through, break
  2. TryStatement — try/catch/finally blocks, exception handler stack
  3. ForOfStatement — iterator protocol
  4. ForInStatement — for-in lowering
  5. WithStatement — error
  6. ClassDeclaration — class expression lowering
  7. Type declarations — skip/pass-through
  8. Import/Export declarations — error
  9. OptionalCallExpression, OptionalMemberExpression — optional chaining
  10. lowerReorderableExpression(), isReorderableExpression()

M13: Polish + Full Test Coverage

Goal: All fixtures pass, no remaining todo!() in production paths.

  1. Remove all remaining todo!() stubs — replace with proper errors for truly unsupported syntax
  2. Run test-rust-port.sh HIR on all 1714 fixtures
  3. Debug and fix any divergences from TypeScript output
  4. Handle edge cases: error recovery, Babel bug workarounds (where applicable), fbt depth tracking

Key Rust Patterns

Pattern 1: Switch/Case → Match

Every switch (stmtPath.type) and switch (exprPath.type) becomes a match on the AST enum. Rust's exhaustive matching ensures no cases are missed (unlike TypeScript where the default arm might hide bugs).

Pattern 2: path.get('field') → Direct Field Access

// TypeScript
const test = stmt.get('test');
const body = stmt.get('body');
// Rust
let test = &stmt.test;
let body = &stmt.body;

Pattern 3: Type Guards → Match Arms

// TypeScript
if (param.isIdentifier()) { ... }
else if (param.isObjectPattern()) { ... }
// Rust
match param {
    ast::PatternLike::Identifier(id) => { ... }
    ast::PatternLike::ObjectPattern(pat) => { ... }
}

Pattern 4: hasNode() → Option Checks

// TypeScript
const alternate = stmt.get('alternate');
if (hasNode(alternate)) { ... }
// Rust
if let Some(alternate) = &stmt.alternate { ... }

Pattern 5: Instruction Construction

// TypeScript
builder.push({
    id: makeInstructionId(0),
    lvalue: { ...place },
    value: { kind: 'LoadGlobal', name, binding, loc },
    effects: null,
    loc: exprLoc,
});
// Rust
builder.push(Instruction {
    id: InstructionId(0), // renumbered by markInstructionIds
    lvalue: place.clone(),
    value: InstructionValue::LoadGlobal { name, binding, loc },
    effects: None,
    loc: expr_loc,
});

Risks and Mitigations

Risk 1: gatherCapturedContext() Without Babel Traverser

Impact: Medium. The TypeScript version uses fn.traverse() to find free variable references. Mitigation: Write a manual AST walker that visits all Identifier nodes in a function body and checks ScopeInfo.reference_to_binding for each one. This is simpler than Babel's traverser because we don't need the full visitor infrastructure — just recursive pattern matching over AST node types.

Risk 2: Variable Capture Across enter() Closures

Impact: Low-Medium. ~15-20 places in BuildHIR.ts assign variables inside enter() closures that are read outside. Mitigation: Case-by-case restructuring. Options include: (a) returning the value from the closure via a tuple, (b) storing it on the builder temporarily, (c) restructuring to compute the value before/after the enter() call. Each instance is small and mechanical.

Risk 3: isReorderableExpression() Recursive Analysis

Impact: Low. This function deeply analyzes expressions to determine reorderability. Mitigation: Direct recursive pattern matching on AST structs — actually simpler in Rust than TypeScript because there's no NodePath overhead.

Risk 4: Optional Chaining Lowering Complexity

Impact: Medium. lowerOptionalCallExpression() and lowerOptionalMemberExpression() (~250 lines combined) generate complex CFG structures with multiple blocks for null checks. Mitigation: Port last (M12), after all simpler patterns are verified. The CFG generation logic maps directly — it's just verbose.

Risk 5: fbt/fbs Special Handling

Impact: Low. The fbt handling in JSXElement lowering uses Babel's path.traverse() for counting nested fbt tags. Mitigation: Replace with a simple recursive AST walk that counts JSXNamespacedName nodes matching the fbt tag name. The fbtDepth counter on the builder is trivial.