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]>
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 theindexmapcrate), notBTreeMap/BTreeSet/HashMap/HashSet. This is critical forHIR.blockswhereBTreeMapdestroys RPO insertion ordering. - Functions
lower_function,lower_function_to_value,gather_captured_context,lower_object_property_key,lower_typetake&Expression. The AST crate usesExpressionfor keys and doesn't have standaloneFunction/ObjectPropertyKey/TypeAnnotationtypes, so&Expressionis correct for the current AST structure. When these functions are implemented, they should pattern-match on the specific expression variants internally. VariableBinding::Identifier.binding_kindisString— must be aBindingKindenum.HirBuilderis missingcomponent_scope: ScopeIdfield (needed forgather_captured_contextin M9).build_temporary_placehelper is missing (listed in M4).mark_predecessorsfallthrough handling: VERIFIED — matches TSeachTerminalSuccessor(does not include fallthroughs, correct).GotoVariant::Breakusage: VERIFIED — matches TS for bothremove_unnecessary_try_catchandremove_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 usesBindingIdfromScopeInfo— the map becomesIndexMap<BindingId, IdentifierId>instead ofMap<string, {node, identifier}>. This is simpler and more correct.isContextIdentifier(): TypeScript checksenv.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 aScopeInfoquery.gatherCapturedContext(): TypeScript traverses the function with Babel's traverser to find free variable references. Rust walks the AST directly usingScopeInfo.reference_to_bindingto 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 dedicatedcompiler_invariant!macroCompilerError.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:
- The code to compile at every step
- Tests to run for fixtures that only use implemented features
- Clear visibility into what remains
- 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_catchandremove_dead_do_while_statements: Verify that theGotoVariantused when replacing terminals matches the TS equivalent. Currently usesGotoVariant::Break— confirm this is correct.mark_predecessors: Theeach_terminal_successorfunction must visit fallthrough blocks for terminals likeTry, not just direct successors. Compare against TSeachTerminalSuccessorbehavior.
Statement Lowering: Match Arm Inventory
The lowerStatement function has ~30 match arms. Grouped by complexity:
Tier 1 — Trivial (1-10 lines each)
EmptyStatement— no-opDebuggerStatement— singleDebuggerinstructionExpressionStatement— delegate tolower_expression_to_temporaryBreakStatement—builder.lookup_break()+ goto terminalContinueStatement—builder.lookup_continue()+ goto terminalThrowStatement— lower expression + throw terminal
Tier 2 — Simple control flow (10-30 lines each)
ReturnStatement— lower expression + return terminalBlockStatement— iterate body statementsIfStatement— reserve blocks, enter consequent/alternate, branch terminalWhileStatement— test block + body block + loop scopeLabeledStatement— delegate with label, or create label scope
Tier 3 — Complex control flow (30-100 lines each)
ForStatement— init/test/update/body blocks, loop scopeForOfStatement— iterator protocol (GetIterator, IteratorNext, etc.)ForInStatement— similar to ForOfDoWhileStatement— body-first loopSwitchStatement— case discrimination with fall-throughTryStatement— try/catch/finally blocks with exception handler stack
Tier 4 — Variable declarations and assignments (30-80 lines)
VariableDeclaration— iterate declarators, handle destructuringFunctionDeclaration— 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 expressionEnumDeclaration/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—PrimitiveinstructionRegExpLiteral—RegExpLiteralinstructionIdentifier— delegate tolower_identifierMetaProperty—LoadGlobalforimport.metaTSNonNullExpression,TSInstantiationExpression— unwrap inner expressionTypeCastExpression,TSAsExpression,TSSatisfiesExpression— unwrap inner expression
Tier 2 — Operators (10-30 lines each)
BinaryExpression— lower operands +BinaryExpressioninstructionUnaryExpression— lower operand +UnaryExpressioninstructionUpdateExpression— 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 keysArrayExpression— elements with holes and spreadsTemplateLiteral— quasis + expressionsTaggedTemplateExpression— tag + template
Tier 4 — Calls and member access (20-50 lines each)
CallExpression— callee + arguments +CallExpression/MethodCallinstructionNewExpression— similar to CallExpressionMemberExpression— object + property +PropertyLoad/ComputedLoadOptionalCallExpression— optional chain with test blocksOptionalMemberExpression— optional chain with test blocks
Tier 5 — Control flow expressions (30-80 lines each)
ConditionalExpression— if-like CFG with value blocksLogicalExpression— short-circuit evaluation with blocksAssignmentExpression— delegates tolower_assignment(destructuring)
Tier 6 — Complex (50-150 lines each)
JSXElement— tag + props + children + fbt handlingJSXFragment— children onlyArrowFunctionExpression/FunctionExpression— recursivelower_functionAwaitExpression— 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—StoreLocalinstruction (with const/let/reassign distinction)MemberExpression—PropertyStore/ComputedStoreinstructionArrayPattern— emitDestructurewithArrayPatterncontaining items, holes, rest elements, and default valuesObjectPattern— emitDestructurewithObjectPatterncontaining properties, computed keys, rest elements, and default valuesAssignmentPattern— 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:
-
Shared Environment: Parent and child share
&mut Environment. This works because the recursive call completes before the parent continues. -
Shared Bindings: The parent's
bindingsmap 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. -
Context gathering:
gatherCapturedContext()walks the function's AST to find free variable references. In Rust, this walks the AST structs usingScopeInfoto identify references that resolve to bindings in ancestor scopes (between the function's scope and the component scope). -
Function arena storage: The returned
HirFunctionis stored inenv.functions(the function arena) and referenced byFunctionIdin theFunctionExpressioninstruction 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!().
-
Create
compiler/crates/react_compiler_diagnostics/withCompilerDiagnostic,CompilerError,ErrorCategory,CompilerErrorDetail,CompilerSuggestionOperation. -
Create
compiler/crates/react_compiler_hir/with core types:- ID newtypes:
BlockId,IdentifierId,InstructionId(index into the flat instruction table),EvaluationOrder(sequential numbering assigned duringmarkInstructionIds()— this was previously calledInstructionIdin the TypeScript compiler),DeclarationId,ScopeId,FunctionId,TypeId HirFunction,HIR,BasicBlock,WipBlock,BlockKindInstruction,InstructionValue(enum with all ~40 variants, each stubbed astodo!()for fields)Terminal(enum with all variants)Place,Identifier,MutableRange,SourceLocationEffect,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 needEq/Hash(stores raw bits viaf64::to_bits()for deterministic comparison)
- ID newtypes:
-
Create
compiler/crates/react_compiler_lowering/with:hir_builder.rs:HirBuilderstruct with all methods stubbedbuild_hir.rs:lower_statement()andlower_expression()with all arms astodo!()lib.rs:pub fn lower()that creates a builder and returnstodo!()
-
Verify:
cargo checkpasses.
M2: HIRBuilder Core
Goal: HIRBuilder methods work — can create blocks, terminate them, build the CFG.
-
Implement
HirBuilder::new(),push(),terminate(),terminate_with_continuation(),reserve(),complete(),enter_reserved(),enter(). -
Implement scope methods:
loop_scope(),label_scope(),switch_scope(),lookup_break(),lookup_continue(). -
Implement
enter_try_catch(),resolve_throw_handler(). -
Implement
make_temporary(),record_error(). -
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.
-
Implement
resolve_binding()— mapsBindingIdtoIdentifierId, creating new identifiers on first encounter. UsesIndexMap<BindingId, IdentifierId>instead of the TypeScriptMap<string, {node, identifier}>. -
Implement
resolve_identifier()— dispatches to Global, ImportDefault, ImportSpecifier, ImportNamespace, ModuleLocal, or Identifier based onScopeInfolookups. -
Implement
is_context_identifier()— checks if a reference resolves to a binding in an ancestor scope. -
Implement
gather_captured_context()— walks AST to find free variable references usingScopeInfo.
M4: lower() Entry Point + Basic Statements
Goal: Can lower simple functions with ReturnStatement, ExpressionStatement, BlockStatement, VariableDeclaration (simple, non-destructuring).
-
Implement the
lower()function body: parameter processing, body lowering, final return terminal,builder.build(). -
Implement statement arms:
ReturnStatementExpressionStatementBlockStatementEmptyStatementVariableDeclaration(simplelet x = expronly, destructuring astodo!())
-
Implement basic expression arms:
Identifier(vialower_identifier)NullLiteral,BooleanLiteral,NumericLiteral,StringLiteralBinaryExpressionUnaryExpression
-
Implement helpers:
lower_expression_to_temporary(),lower_value_to_temporary(),build_temporary_place(). -
Test: Run
test-rust-port.sh HIRon simple fixtures.
M5: Control Flow
Goal: Branches and loops work.
IfStatement— consequent/alternate blocks, branch terminalWhileStatement— test/body blocks, loop scopeForStatement— init/test/update/body blocksDoWhileStatement— body-first loop patternBreakStatement,ContinueStatementLabeledStatement
M6: Expressions — Calls and Members
Goal: Function calls and property access work.
CallExpression— including method calls (callee is MemberExpression)NewExpressionMemberExpression— PropertyLoad/ComputedLoadlower_arguments()— spread handlingSequenceExpression
M7: Expressions — Short-circuit and Ternary
Goal: Control-flow expressions produce correct CFG.
ConditionalExpression— if-like structure with value blocksLogicalExpression— short-circuit&&,||,??AssignmentExpression— simple identifier/member assignment (destructuring deferred)
M8: Expressions — Remaining
Goal: All expression types handled.
ObjectExpression— properties, methods, computed, spreadArrayExpression— elements, holes, spreadsTemplateLiteral,TaggedTemplateExpressionUpdateExpression— prefix/postfix increment/decrementRegExpLiteralAwaitExpressionTypeCastExpression,TSAsExpression,TSSatisfiesExpression,TSNonNullExpression,TSInstantiationExpressionMetaProperty
M9: Function Expressions + Recursive Lowering
Goal: Nested functions work.
ArrowFunctionExpression,FunctionExpression— calllower_function()lower_function()— recursivelower()with captured contextgather_captured_context()— AST walk for free variables- Function arena storage via
FunctionId FunctionDeclarationstatement — hoisted function lowering
M10: JSX
Goal: JSX elements and fragments lower correctly.
JSXElement— tag, props, children, fbt handlingJSXFragment— childrenlower_jsx_element_name()— identifier, member expression, builtin tag dispatchlower_jsx_element()— child lowering (text, expression, element, spread)lower_jsx_member_expression()trimJsxText()— whitespace normalization
M11: Destructuring + Complex Assignments
Goal: Full destructuring support.
lower_assignment()forArrayPattern— items, holes, rest, defaultslower_assignment()forObjectPattern— properties, computed keys, rest, defaultslower_assignment()forAssignmentPattern— default valuesVariableDeclarationwith destructuring patterns- Param destructuring in
lower()entry point
M12: Switch + Try/Catch + Remaining
Goal: All statement types handled, complete coverage.
SwitchStatement— case discrimination, fall-through, breakTryStatement— try/catch/finally blocks, exception handler stackForOfStatement— iterator protocolForInStatement— for-in loweringWithStatement— errorClassDeclaration— class expression lowering- Type declarations — skip/pass-through
- Import/Export declarations — error
OptionalCallExpression,OptionalMemberExpression— optional chaininglowerReorderableExpression(),isReorderableExpression()
M13: Polish + Full Test Coverage
Goal: All fixtures pass, no remaining todo!() in production paths.
- Remove all remaining
todo!()stubs — replace with proper errors for truly unsupported syntax - Run
test-rust-port.sh HIRon all 1714 fixtures - Debug and fix any divergences from TypeScript output
- 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.