Port React Compiler to Rust
Source: Hacker News
Replace the "rename to error.todo-*" approach for the six Flow `match`
fixtures with actual support, ported from pr-36173 commits 0dc7f2e
and d8aae6b. npm hermes-parser CAN parse match syntax: it requires
hermes-parser >= 0.28 plus the enableExperimentalFlowMatchSyntax
parser option (snap pinned 0.25.1 and never passed the flag; 0.26
carries an incompatible draft grammar).
- Un-rename the six match fixtures from error.todo-* back to their original names, restoring the pre-rename inputs (hermes-canonical formatting) and their real compiled snapshots: match-expr-captured-var.flow.js match-expr-jsx-spread.flow.js match-expr-multi-gen-bindings.flow.js match-expr-outlined-jsx.flow.js match-expression-with-tuple-and-early-return.js match-stmt-self-ref-const.flow.js All six pass against the checked-in snapshots with no regeneration (yarn snap -p ‘match-*’: 6 Tests, 6 Passed, 0 Failed).
- snap: hermes-parser ^0.28.0 in snap/package.json (+compiler yarn.lock; babel-plugin-syntax-hermes-parser stays 0.25.1 since nothing in snap’s pipeline re-parses match syntax), pass enableExperimentalFlowMatchSyntax in parseInput, add the option to the hermes-parser module type in types.d.ts. compiler/yarn.lock also gains the previously-missing typescript entry for the babel-plugin-react-compiler-rust workspace (pre-existing drift that any yarn install regenerates).
- method-call-scope-merge-mutable-range-sync: rename tr/td to div/span (valid DOM nesting). The bare in sprout’s container triggered a validateDOMNesting warning in exactly one of the two evaluations (warning dedup shares process state), so logs differed while rendered output was identical; this was the 1 failing test at baseline. Compiled shape unchanged; snapshot diff is tag literals only.
- prettier: format the match fixtures for real instead of ignoring them. Remove the .prettierignore match-* globs (stale since the error.todo-* rename: they no longer matched any file, which is why the prettier check failed at baseline with 6 parse errors). Add a .prettierrc.js override scoped to the match-* fixture paths using prettier-plugin-hermes-parser (root devDep at ^0.32.0 + root yarn.lock), whose parser handles the experimental syntax.
- TS_SKIP_FIXTURES: no entries removed; the current list (9 entries) contains no match-related fixtures. The match fixtures were handled entirely by the error.todo-* rename, which this commit reverts. The snapshots-reflect-Rust-output semantics and skip machinery are kept as-is.
Test plan:
-
yarn snap: 1800 Tests, 1800 Passed, 0 Failed (baseline: 1800 Tests, 1799 Passed, 1 Failed)
-
yarn snap -p ‘match-*’ -v: 6 Tests, 6 Passed, 0 Failed
-
node scripts/prettier/index.js: exit 0 (baseline: exit 1 with parse errors on the six match fixtures)
-
bash compiler/scripts/test-babel-ast.sh: parse 1771/1799 (28 parse errors, unchanged: @babel/parser cannot parse match syntax so those fixtures remain excluded from the round-trip corpus, exactly as before the un-rename); round_trip 1782/1782; scope info 1783/1783; rename 1767/1767 (12 skipped); exit 0
 Pin how TSImportEqualsDeclaration, TSExportAssignment, and
TSNamespaceExportDeclaration must behave: the statement is preserved in output and the file’s functions still compile, as the TS reference already does. The three frontends share the broken symptom today via three different root causes: the Babel/NAPI path throws “Failed to parse AST JSON: unknown variant …” (the typed AST’s tagged enums have no catch-all) and fails the whole file; the SWC converter explicitly rewrites the statements to EmptyStatement, erasing them from output with no error and no event; OXC todo!()-panics in its converter (deferred).
The fixtures use the bare todo- prefix rather than error.: snap asserts error. fixtures throw on the TS side, and these compile cleanly there. All three function bodies allocate so the compiled snapshots visibly memoize; combined with the e2e events comparison, a degenerate whole-file bailout cannot pass them.
Known-red until the fix slices land: Babel and SWC e2e on these three fixtures, and test-babel-ast.sh (both round_trip and scope_resolution_rename deserialize the same fixture JSON). TS-side snap is green. SproutTodoFilter skips only the namespace fixture: export as namespace is .d.ts-shaped and sprout’s evaluator transform cannot process it; the other two transform to CJS and evaluate fine.

Babel can emit statement kinds the typed AST does not model (the
todo-ts-* fixtures pin three TS module-interop forms). Deserialization previously failed the whole file on the first such node, while the TS reference compiles the file and leaves the statement alone.
Statement gains a final #[serde(untagged)] Unknown(UnknownStatement)
variant carrying the complete raw node. Deserialization is hand-written
and dispatches modeled type tags through a KnownStatement helper so a
malformed modeled node still errors with its precise field-level
message instead of degrading to Unknown; only genuinely unmodeled tags
take the catch-all. The TS reference reaches its equivalent default
case only via assertExhaustive (Babel’s closed types), so it crashes;
here unmodeled syntax is reachable by construction and degrades
instead: top-level statements are preserved verbatim through
re-serialization, and function-body occurrences record the standard
UnsupportedSyntax bailout with an UnsupportedNode instruction carrying
the raw node. A known_statements! macro is the single source for the
dispatch enum, its From mapping, and the tag list, so those three
cannot drift; a variant added to Statement but not the macro is the one
remaining silent gap, documented on the variant.
UnknownStatement caches BaseNode for position helpers; the scoped
with_raw_mut mutator refreshes the cache and rejects mutations that
strip type, so the two views cannot desync. Program-level analyses
treat Unknown explicitly: the gating reference-before-declaration scan
walks the raw node for identifier references (an export = X does
reference X), and the prefilter and return-analysis arms are
deliberately inert. SWC/OXC reverse converters emit a deliberate
runtime tripwire (a throw in generated code) for the arms that are
unreachable until the SWC forward conversion stops rewriting these
statements to EmptyStatement in the next slice.
Deserialization now materializes a serde_json::Value per statement before typed parsing. The cost is one move-based tree rebuild per nesting level at a one-time boundary; the previous derive also buffered every node through serde’s internal Content to read the tag, so the delta is allocation shape, not asymptotics.
Verified: ast unit tests including malformed/edge cases, a lowering integration test pinning the function-body bailout, round_trip green on the three fixtures, scoped and full Babel e2e green on all three with events parity, cargo test —workspace green. The scope-resolution half of test-babel-ast.sh is green on this stack’s base and remains red corpus-wide on the pr-36173 tip, whose node-ID migration removed position-based keying while babel-ast-to-json.mjs still emits offset-based scope JSON; that generator gap needs its own fix before this stack rebases onto the tip. rust-port-0001-babel-ast.md’s no-catch-all policy is amended to document Statement as the deliberate exception.
Port adaptation for this branch’s UnsupportedNode codegen fix (0957b55), which discriminated statement-vs-expression original_node by attempting a Statement deserialization. With the tolerant deserializer that attempt succeeds for every tagged object, which would silently emit expression nodes as raw statements and orphan their lvalue temporaries — regressing the ~10 fixtures that commit fixed. The codegen site now discriminates explicitly (codegen_unsupported_original_node): modeled statement tags parse typed and a parse failure is an invariant, not a degrade; tags that parse as Expression or PatternLike (both strict enums, no catch-all) flow through expression codegen unchanged, preserving the lvalue binding and the pattern placeholder fallback; only genuinely unmodeled tags — producible solely by the unknown-statement lowering bailout, i.e. from statement position — degrade to Statement::Unknown and are emitted verbatim, matching TS codegen’s ‘return node’. is_known_statement_type is now exposed (pub) from the known_statements! macro for this, and unit tests pin the dispatch (modeled statement tag, malformed modeled tag, expression tag, pattern tag, unknown tag).

…tend
The SWC converter rewrote TSImportEqualsDeclaration, TSExportAssignment, and TSNamespaceExportDeclaration to EmptyStatement, silently deleting them from output with no error and no event. Route them through the same Statement::Unknown carrier the Babel path uses: the forward converter builds the Babel-compatible raw node (field names and nesting match @babel/parser; importKind/isExport carried; qualified TSQualifiedName refs supported), and the reverse converter rebuilds real swc module declarations at the ModuleItem layer, deserializing sub-fields through the typed AST and reusing the existing expression conversion. Malformed raw shapes, including invalid importKind or isExport types, return None and hit the loud Stmt-level tripwire rather than degrading.
swc_ecma_codegen v24 misprints TsNamespaceExportDecl as the TsExportAssignment shape (“export = Foo”); the bug is also on swc master and a genuine export assignment prints byte-identical text, so the affected lines cannot be identified from output text alone. The ts_namespace_export_fixup module anchors the rewrite on the source map the emitter records: candidates are positions within the declaration’s span (the v24 emitter records only the identifier’s span.lo, pinned by a test), filtered by content verification because compiler-generated imports carry synthetic spans that collide with a first-statement declaration’s span.lo. Unlocatable declarations panic; the silent alternative emits a semantically different statement. A guard test asserts raw swc still misprints the node, so an swc upgrade that fixes the bug fails the test and prompts deleting the module.
The fixtures drop their todo- prefix (ts-import-equals-declaration, ts-export-assignment, ts-namespace-export-declaration) now that both Babel and SWC are green; the SproutTodoFilter entry follows the rename (sprout’s TS->CJS transform still cannot process export-as-namespace). OXC remains deferred and documented.
Verified: react_compiler_swc 51 tests green including round trips for import type / export import forms, UMD pairs, decoy template-literal and block-comment lines, and the synthetic-span collision; workspace 78 green; all three fixtures pass scoped e2e on both variants including events parity; full swc 1783/1798 and babel 1791/1798 with failure lists identical to the documented baselines.
Port note: re-measured on this branch (lauren/port-rust-research, fork corpus 1799 -> 1802 with the three fixtures): cargo workspace 84 green; yarn snap 1803/1803; full e2e babel 1792/1802 and swc 1786/1802 with failure sets byte-identical to the pre-stack baseline at 2aa3f0c (10 babel / 16 swc, none involving the ts-* fixtures). TODO.md’s status snapshot is updated to these measured numbers and the inherited SWC triage section is marked historical like its siblings.


CompileError logger events carry plain-object details (normalized for
Rust/TS logger parity), but the playground pushed event.detail straight into CompilerError.details. Printing the error then crashed with “detail.printErrorMessage is not a function”, leaving the Next.js error overlay up so Monaco never loaded and the source-syntax-error e2e test timed out on every retry. Reconstruct CompilerDiagnostic / CompilerErrorDetail instances at the logEvent boundary so downstream consumers keep their method-based API.

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. 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(() => …) 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.


…semantics
Four root causes, all in how the port approximated Babel/TS traversal:
-
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).
-
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).
-
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.
-
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 , 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.