From 08cf7d607f304cf2c701b41ab9cf70b44f6fc167 Mon Sep 17 00:00:00 2001 From: lauren Date: Wed, 20 May 2026 23:43:06 -0700 Subject: [PATCH 1/6] [rust-compiler] Emit loc.column/index as UTF-16 code units in SWC frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the cluster-1 BytePos shift, `ConvertCtx::position()` emitted `loc.column` and `loc.index` as 0-based UTF-8 byte offsets. Babel emits them as 0-based UTF-16 code unit offsets (matching JS string indexing). For files containing any character above U+FFFF (e.g. an emoji like πŸ”΄ U+1F534), the two diverge by +2 per such character because the char is 4 bytes in UTF-8 but 2 code units in UTF-16. Precompute a `utf16_offsets: Vec` table in `ConvertCtx::new` that maps each source byte index to its 0-based UTF-16 code unit offset. `position()` then looks up `index` directly and computes `column` as `index - utf16_index_of_line_start`. O(1) per call; the table costs ~4Γ— the source length in memory, which is bounded for fixture/file inputs. Considered an alternative that walks the source line on each `position()` call to count UTF-16 code units. More memory-frugal but O(line length) per call. The precomputed table wins on O(1) lookup and the per-call cost matters because `position()` is invoked on every node, comment, and reference in the converter. Clamp the byte index in `position()` to the sentinel at `utf16_offsets.len() - 1`. Synthetic spans (e.g. compiler-generated imports given `BytePos(1)`) can point past EOF in degenerate cases; clamping avoids a panic. Line numbers stay 1-based and the binary-search remains keyed on byte offsets, since the underlying `line_offsets` table is byte-based. Fixes 4 e2e parity fixtures (3 targeted + 1 latent): - effect-derived-computations/invalid-derived-computation-in-effect.js - error.invalid-derived-computation-in-effect.js - fbt/error.todo-multiple-fbt-plural.tsx - (one additional latent fixture passes for free) Test plan: - bash compiler/scripts/test-e2e.sh --variant swc: Before: Total 1770/1795 After: Total 1774/1795 (4 fixed) - bash compiler/scripts/test-e2e.sh --variant babel: 1788/1795 (unchanged) - bash compiler/scripts/test-e2e.sh --variant oxc: 1702/1795 (unchanged) - cargo test --workspace: 56 passed, 0 failed --- .../react_compiler_swc/src/convert_ast.rs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/compiler/crates/react_compiler_swc/src/convert_ast.rs b/compiler/crates/react_compiler_swc/src/convert_ast.rs index 319ff59e2a2..651d9f8b21f 100644 --- a/compiler/crates/react_compiler_swc/src/convert_ast.rs +++ b/compiler/crates/react_compiler_swc/src/convert_ast.rs @@ -191,19 +191,27 @@ struct ConvertCtx<'a> { #[allow(dead_code)] source_text: &'a str, line_offsets: Vec, + utf16_offsets: Vec, } impl<'a> ConvertCtx<'a> { fn new(source_text: &'a str) -> Self { let mut line_offsets = vec![0u32]; + let mut utf16_offsets = vec![0u32; source_text.len() + 1]; + let mut utf16_offset = 0u32; for (i, ch) in source_text.char_indices() { + let next = i + ch.len_utf8(); + utf16_offsets[i..next].fill(utf16_offset); + utf16_offset += ch.len_utf16() as u32; if ch == '\n' { - line_offsets.push((i + 1) as u32); + line_offsets.push(next as u32); } } + utf16_offsets[source_text.len()] = utf16_offset; Self { source_text, line_offsets, + utf16_offsets, } } @@ -222,7 +230,7 @@ impl<'a> ConvertCtx<'a> { } } - /// `BytePos` is 1-based; emit 0-based `loc` to match Babel. + /// `BytePos` is 1-based; emit 0-based UTF-16 `loc` to match Babel. /// (`BaseNode.start`/`end` stays 1-based: `convert_scope` keys on it.) fn position(&self, offset: u32) -> Position { let zero_based = offset.saturating_sub(1); @@ -231,10 +239,14 @@ impl<'a> ConvertCtx<'a> { Err(idx) => idx.saturating_sub(1), }; let line_start = self.line_offsets[line_idx]; + // Synthetic spans can point past EOF; clamp to the sentinel. + let byte_idx = (zero_based as usize).min(self.utf16_offsets.len() - 1); + let utf16_offset = self.utf16_offsets[byte_idx]; + let line_start_utf16 = self.utf16_offsets[line_start as usize]; Position { line: (line_idx as u32) + 1, - column: zero_based - line_start, - index: Some(zero_based), + column: utf16_offset - line_start_utf16, + index: Some(utf16_offset), } } From 90b4f647bc25877c80622eadaa073892b5400c5a Mon Sep 17 00:00:00 2001 From: lauren Date: Wed, 20 May 2026 22:26:01 -0700 Subject: [PATCH 2/6] [rust-compiler] Treat redeclared functions as one binding in SWC scope info `convert_scope.rs::visit_fn_decl` allocated a fresh `BindingId` for every `function x()` declaration. Babel and OXC collapse same-named function declarations in the same hoist scope into one binding, with the second declaration registered as a `constantViolations` reference (reassignment) rather than a new binding. For `function-declaration-redeclare.js` the SWC variant emitted `const x_0 = t0; return x_0;` because the compiler saw two distinct bindings and renamed one. Babel's output is `let x; ... x = t0; return x;` because there is one binding that gets reassigned. In `visit_fn_decl`, check whether the hoist scope already has a binding for the name. If yes, record the redeclaration's ident position in a new `redeclaration_refs` map and skip adding a fresh binding. `build_scope_info` overlays this map onto `reference_to_binding` so the second function's ident resolves to the first binding's `BindingId`. Fixes 1 e2e parity fixture: - function-declaration-redeclare.js `valid-setState-in-effect-from-ref-function-call.js` and its sibling `valid-setState-in-useEffect-controlled-by-ref-value.js` still fail. Those have a distinct root cause: the SWC frontend discards the compiler's `renames` output (`lib.rs:91-98`) instead of applying it to the emitted SWC AST the way the babel adapter does via `applyRenames`. That fix is its own commit. Test plan: - bash compiler/scripts/test-e2e.sh --variant swc: Before: Total 1774/1795 After: Total 1775/1795 (1 fixed) - bash compiler/scripts/test-e2e.sh --variant babel: 1788/1795 (unchanged) - bash compiler/scripts/test-e2e.sh --variant oxc: 1702/1795 (unchanged) - cargo test --workspace: 56 passed, 0 failed --- .../react_compiler_swc/src/convert_scope.rs | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/compiler/crates/react_compiler_swc/src/convert_scope.rs b/compiler/crates/react_compiler_swc/src/convert_scope.rs index fc81bfddef3..288201f3a97 100644 --- a/compiler/crates/react_compiler_swc/src/convert_scope.rs +++ b/compiler/crates/react_compiler_swc/src/convert_scope.rs @@ -45,6 +45,12 @@ pub fn build_scope_info(module: &Module) -> ScopeInfo { } } + // Function redeclarations: resolve the second `function x()`'s ident + // to the first declaration's binding (overwrites any earlier entry). + for (start, binding_id) in &collector.redeclaration_refs { + resolver.reference_to_binding.insert(*start, *binding_id); + } + resolver.reference_to_binding }; @@ -72,6 +78,11 @@ struct ScopeCollector { /// Set of span starts for block statements that are direct function/catch bodies. /// These should NOT create a separate Block scope. function_body_spans: HashSet, + /// Function declarations that redeclare an existing hoisted name resolve to + /// the first binding's `BindingId` (like babel's `constantViolations`), so + /// the compiler treats the redeclaration as a reassignment rather than a + /// new binding. + redeclaration_refs: HashMap, } impl ScopeCollector { @@ -83,6 +94,7 @@ impl ScopeCollector { node_to_scope_end: HashMap::new(), scope_stack: Vec::new(), function_body_spans: HashSet::new(), + redeclaration_refs: HashMap::new(), } } @@ -344,14 +356,20 @@ impl Visit for ScopeCollector { let hoist_scope = self.enclosing_function_scope(); let name = fn_decl.ident.sym.to_string(); let start = fn_decl.ident.span.lo.0; - self.add_binding( - name, - BindingKind::Hoisted, - hoist_scope, - "FunctionDeclaration".to_string(), - Some(start), - None, - ); + if let Some(&existing_id) = + self.scopes[hoist_scope.0 as usize].bindings.get(&name) + { + self.redeclaration_refs.insert(start, existing_id); + } else { + self.add_binding( + name, + BindingKind::Hoisted, + hoist_scope, + "FunctionDeclaration".to_string(), + Some(start), + None, + ); + } self.visit_function_inner(&fn_decl.function); } From 78afea419e8d8ed22b6f7ffda4a47eb756f876fc Mon Sep 17 00:00:00 2001 From: lauren Date: Wed, 20 May 2026 23:43:06 -0700 Subject: [PATCH 3/6] [rust-compiler] Apply compiler renames to SWC module after conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The React Compiler's `RenameVariables` pass records identifier renames in `CompileResult::Success.renames` as `Vec` with `{original, renamed, declaration_start}`. It does not rewrite the AST itself; the frontend has to apply them. The Babel adapter does this via `applyRenames` + `scope.rename()` in `BabelPlugin.ts:206-234`. The SWC frontend was discarding the field via `..` in the `CompileResult::Success` destructure, so inner-shadowing renames like `ref β†’ ref_0` never made it into the emitted output. Add `apply_renames.rs` with a single pass that mirrors the Babel semantics: 1. Re-run `build_scope_info` on the post-compile SWC module to get a binding table and `reference_to_binding` keyed by source position. 2. Match `BindingRenameInfo` entries to bindings by `declaration_start` and `name`, producing the set of `BindingId`s to rename. 3. Walk `reference_to_binding` to collect every position (declaration plus references) belonging to a renamed binding. 4. A `VisitMut` rewrites `Ident.sym` at matching `span.lo.0`, expands `Prop::Shorthand` and `ObjectPatProp::Assign` (with or without a default) into key-value form so `{ref}` becomes `{ref: ref_0}` instead of `{ref_0}`, and skips `MemberProp::Ident` so `x.ref` stays `x.ref`. Wire into `lib.rs::transform`: destructure `renames` from the `CompileResult::Success` arm, and if `program_json` is `None` but `renames` is non-empty (the `@outputMode:"lint"` path) clone the original input module so we still have something to rewrite. Fixes 2 e2e parity fixtures: - valid-setState-in-effect-from-ref-function-call.js (`ref β†’ ref_0`) - valid-setState-in-useEffect-controlled-by-ref-value.js (`data β†’ data_0`) Test plan: - bash compiler/scripts/test-e2e.sh --variant swc: Before: Total 1775/1795 After: Total 1777/1795 (2 fixed) - bash compiler/scripts/test-e2e.sh --variant babel: 1788/1795 (unchanged) - bash compiler/scripts/test-e2e.sh --variant oxc: 1702/1795 (unchanged) - cargo test --workspace: 56 passed, 0 failed --- .../react_compiler_swc/src/apply_renames.rs | 133 ++++++++++++++++++ compiler/crates/react_compiler_swc/src/lib.rs | 16 ++- 2 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 compiler/crates/react_compiler_swc/src/apply_renames.rs diff --git a/compiler/crates/react_compiler_swc/src/apply_renames.rs b/compiler/crates/react_compiler_swc/src/apply_renames.rs new file mode 100644 index 00000000000..f20d9494a34 --- /dev/null +++ b/compiler/crates/react_compiler_swc/src/apply_renames.rs @@ -0,0 +1,133 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +use std::collections::HashMap; + +use react_compiler::entrypoint::compile_result::BindingRenameInfo; +use react_compiler_ast::scope::BindingId; +use swc_atoms::Atom; +use swc_ecma_ast::*; +use swc_ecma_visit::{VisitMut, VisitMutWith}; + +use crate::convert_scope::build_scope_info; + +pub fn apply_renames(module: &mut Module, renames: &[BindingRenameInfo]) { + if renames.is_empty() { + return; + } + + let scope_info = build_scope_info(module); + let renames_by_declaration: HashMap = renames + .iter() + .map(|rename| (rename.declaration_start, rename)) + .collect(); + let mut renamed_bindings: HashMap = HashMap::new(); + + for binding in &scope_info.bindings { + let Some(rename) = binding + .declaration_start + .and_then(|start| renames_by_declaration.get(&start)) + else { + continue; + }; + if binding.name == rename.original { + renamed_bindings.insert(binding.id, rename.renamed.clone()); + } + } + + if renamed_bindings.is_empty() { + return; + } + + let rewrite_plan: HashMap = scope_info + .reference_to_binding + .iter() + .filter_map(|(&position, binding_id)| { + renamed_bindings.get(binding_id).map(|renamed| (position, renamed.clone())) + }) + .collect(); + + module.visit_mut_with(&mut RenameApplyVisitor { rewrite_plan }); +} + +struct RenameApplyVisitor { + rewrite_plan: HashMap, +} + +impl RenameApplyVisitor { + fn renamed_at(&self, position: u32) -> Option { + self.rewrite_plan.get(&position).cloned() + } +} + +impl VisitMut for RenameApplyVisitor { + fn visit_mut_ident(&mut self, ident: &mut Ident) { + if let Some(renamed) = self.renamed_at(ident.span.lo.0) { + ident.sym = Atom::from(renamed); + } + } + + fn visit_mut_member_expr(&mut self, member: &mut MemberExpr) { + member.obj.visit_mut_with(self); + if let MemberProp::Computed(computed) = &mut member.prop { + computed.visit_mut_with(self); + } + } + + fn visit_mut_prop(&mut self, prop: &mut Prop) { + match prop { + Prop::Shorthand(ident) => { + if let Some(renamed) = self.renamed_at(ident.span.lo.0) { + let mut value = ident.clone(); + value.sym = Atom::from(renamed); + // Shorthand `{ref}` must become `{ref: ref_0}` to preserve property semantics. + *prop = Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName { + span: ident.span, + sym: ident.sym.clone(), + }), + value: Box::new(Expr::Ident(value)), + }); + } + } + Prop::Assign(assign) => { + assign.value.visit_mut_with(self); + } + _ => prop.visit_mut_children_with(self), + } + } + + fn visit_mut_object_pat_prop(&mut self, prop: &mut ObjectPatProp) { + match prop { + ObjectPatProp::Assign(assign) => { + if let Some(value) = &mut assign.value { + value.visit_mut_with(self); + } + + if let Some(renamed) = self.renamed_at(assign.key.id.span.lo.0) { + let mut binding = assign.key.clone(); + binding.id.sym = Atom::from(renamed); + let value = match assign.value.take() { + Some(default_value) => Pat::Assign(AssignPat { + span: assign.span, + left: Box::new(Pat::Ident(binding)), + right: default_value, + }), + None => Pat::Ident(binding), + }; + + *prop = ObjectPatProp::KeyValue(KeyValuePatProp { + key: PropName::Ident(IdentName { + span: assign.key.id.span, + sym: assign.key.id.sym.clone(), + }), + value: Box::new(value), + }); + } + } + _ => prop.visit_mut_children_with(self), + } + } +} diff --git a/compiler/crates/react_compiler_swc/src/lib.rs b/compiler/crates/react_compiler_swc/src/lib.rs index 1d6576771ac..cbd5bcc120f 100644 --- a/compiler/crates/react_compiler_swc/src/lib.rs +++ b/compiler/crates/react_compiler_swc/src/lib.rs @@ -6,9 +6,11 @@ pub mod convert_ast; pub mod convert_ast_reverse; pub mod convert_scope; +pub mod apply_renames; pub mod diagnostics; pub mod prefilter; +use apply_renames::apply_renames; use convert_ast::convert_module_with_source_type; use convert_ast_reverse::convert_program_to_swc_with_source; use convert_scope::build_scope_info; @@ -88,13 +90,16 @@ pub fn transform( react_compiler::entrypoint::program::compile_program(file, scope_info, options); let diagnostics = compile_result_to_diagnostics(&result); - let (program_json, events) = match result { + let (program_json, events, renames) = match result { react_compiler::entrypoint::compile_result::CompileResult::Success { - ast, events, .. - } => (ast, events), + ast, + events, + renames, + .. + } => (ast, events, renames), react_compiler::entrypoint::compile_result::CompileResult::Error { events, .. - } => (None, events), + } => (None, events, Vec::new()), }; let conversion_result = program_json.and_then(|raw_json| { @@ -109,6 +114,7 @@ pub fn transform( let (mut swc_module, mut comments) = match conversion_result { Some(result) => (Some(result.module), Some(result.comments)), + None if !renames.is_empty() => (Some(module.clone()), None), None => (None, None), }; @@ -155,6 +161,8 @@ pub fn transform( } } + apply_renames(swc_mod, &renames); + let (source_leading_comments, source_trailing_comments) = extract_source_comments(source_text); if !source_leading_comments.is_empty() || !source_trailing_comments.is_empty() { From 8ba282982c5401ab3c682b69827ef0c54d5f69c7 Mon Sep 17 00:00:00 2001 From: lauren Date: Wed, 20 May 2026 22:57:33 -0700 Subject: [PATCH 4/6] [rust-compiler] Inject CLI filename into PluginOptions for SWC and OXC The e2e CLI (`react_compiler_e2e_cli`) accepted `--filename ` and used it only for syntax detection. The value was never copied into `PluginOptions.filename`, so the compiler's instrumentation pass (`enableEmitInstrumentForget`) received `None` and emitted `useRenderCounter("Bar", "")` with an empty path string where the TS baseline emits the absolute file path. Set `options.filename = Some(filename.to_string())` at the top of both `compile_swc` and `compile_oxc`. One line each. Fixes 5 e2e parity fixtures: - codegen-instrument-forget-test.js (swc) - conflict-codegen-instrument-forget.js (swc) - gating/codegen-instrument-forget-gating-test.js (swc) - (and 2 corresponding OXC variants of the same fixtures, fixed for free) Test plan: - bash compiler/scripts/test-e2e.sh --variant swc: Before: Total 1777/1795 After: Total 1780/1795 (3 fixed) - bash compiler/scripts/test-e2e.sh --variant babel: 1788/1795 (unchanged) - bash compiler/scripts/test-e2e.sh --variant oxc: Before: Total 1702/1795 After: Total 1704/1795 (2 fixed) - cargo test --workspace: 56 passed, 0 failed --- compiler/crates/react_compiler_e2e_cli/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/compiler/crates/react_compiler_e2e_cli/src/main.rs b/compiler/crates/react_compiler_e2e_cli/src/main.rs index e443026a688..84542fc9cde 100644 --- a/compiler/crates/react_compiler_e2e_cli/src/main.rs +++ b/compiler/crates/react_compiler_e2e_cli/src/main.rs @@ -128,7 +128,8 @@ fn determine_swc_syntax(_filename: &str) -> swc_ecma_parser::Syntax { }) } -fn compile_swc(source: &str, filename: &str, options: PluginOptions) -> CompileOutput { +fn compile_swc(source: &str, filename: &str, mut options: PluginOptions) -> CompileOutput { + options.filename = Some(filename.to_string()); let cm = swc_common::sync::Lrc::new(swc_common::SourceMap::default()); let fm = cm.new_source_file( swc_common::sync::Lrc::new(swc_common::FileName::Anon), @@ -210,7 +211,8 @@ fn compile_swc(source: &str, filename: &str, options: PluginOptions) -> CompileO } } -fn compile_oxc(source: &str, filename: &str, options: PluginOptions) -> CompileOutput { +fn compile_oxc(source: &str, filename: &str, mut options: PluginOptions) -> CompileOutput { + options.filename = Some(filename.to_string()); // Always enable TypeScript parsing (like the TS/Babel baseline uses // ['typescript', 'jsx'] plugins). Some .js fixtures contain TS syntax. // Check for @script pragma in the first line to use script source type. From ba729ad2777e9d92bc4be82fc29e2a1a5e28e59c Mon Sep 17 00:00:00 2001 From: lauren Date: Wed, 20 May 2026 23:04:19 -0700 Subject: [PATCH 5/6] [rust-compiler] Document remaining e2e parity TODOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot of remaining e2e failures across all three frontends: - SWC: 1780/1795 (15 failures), grouped by fix path β€” fixture maintenance for TS-bug-Rust-correct cases (6), external PR dependency (1), and real SWC frontend bugs (8). Each line names the fixture, the failure shape, and where to look. - Babel: 1788/1795 (7 failures) β€” placeholder; needs triage. - OXC: 1704/1795 (91 failures) β€” placeholder; needs triage. Companion artifact to the current 9-commit SWC-correctness stack. --- compiler/crates/TODO.md | 156 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 compiler/crates/TODO.md diff --git a/compiler/crates/TODO.md b/compiler/crates/TODO.md new file mode 100644 index 00000000000..0d3b0c96efc --- /dev/null +++ b/compiler/crates/TODO.md @@ -0,0 +1,156 @@ +# Rust port: e2e parity TODO + +Status snapshot (after the current stack lands): + +| Variant | Score | Failures | +| ------- | ------------ | -------- | +| Babel | 1788 / 1795 | 7 | +| SWC | 1780 / 1795 | 15 | +| OXC | 1704 / 1795 | 91 | + +`cargo test --workspace`: 56 passed, 0 failed. + +## SWC + +The 15 remaining SWC e2e failures fall into three groups. Each line names the +fixture and the failure mode; the group it sits in dictates the appropriate +fix. + +### Group A: Fixture maintenance, not Rust bugs + +SWC compiles code that TS rejects, or vice versa, in ways where Rust's +behavior is arguably correct. The fix is to rename the fixture (drop the +`error.` prefix) and update the `.expect.md` snapshot so the suite stops +asserting the TS-specific output. + +- `error.bug-invariant-local-or-context-references.js` β€” TS fires + `CompilerError::invariant` ("expected all references ... consistently + local or context"). Rust handles the same code without tripping the + invariant. +- `error.todo-jsx-intrinsic-tag-matches-local-binding.js` β€” SWC pipeline + emits a Todo bailout (`[hoisting] EnterSSA: Expected identifier to be + defined before being used`) that the Babel path does not. +- `error.todo-repro-named-function-with-shadowed-local-same-name.js` β€” + Babel errors; SWC compiles. +- `new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js` + β€” same as above with the new mutation-aliasing model enabled. +- `error.todo-rust-as-expression-assignment-target.tsx` β€” Babel errors; + SWC compiles. +- `fbt/error.todo-locally-require-fbt.js` β€” Babel emits the + `Invariant: tags should be module-level imports` shape; SWC emits + `Todo: Local variables named 'fbt' may conflict with the fbt plugin`. + Different categories, both reasonable. + +### Group B: External dependency + +- `use-no-forget-multiple-with-eslint-suppression.js` β€” spurious + `import { c as _c }` in the TS reference output. Fixed on `main` by + [react#36500](https://github.com/facebook/react/pull/36500) (merged). + Will pass automatically once `pr-36173` rebases onto `main`; until then + the TS dist built from `pr-36173` still emits the unused import. + +### Group C: Real SWC frontend bugs + +Each line names the failure mode and a sketch of where to look. + +- `fbt/fbt-param-with-quotes.js` β€” SWC codegen emits double quotes + (`"fbt"`) and reformats multi-line JSX into a single line; Babel uses + single quotes and preserves the source layout. Semantically equivalent + output; the fix is either an SWC codegen flag for quote style or a + post-emit pass. Low impact, high effort. + +- `lone-surrogate-string-values.js` β€” TS preserves lone surrogates + (`\uD83E`); SWC emits `\uFFFD` because `Wtf8Atom::to_string_lossy()` in + `react_compiler_swc/src/convert_ast.rs::wtf8_to_string` replaces invalid + UTF-8 sequences. Real WTF-8 handling work that touches every call site + using that helper. Probably needs to detect lone surrogates and emit + `\uXXXX` escapes before they hit `String`. + +- `many-scopes-no-stack-overflow.js` β€” TS memoizes the function + (`const $ = _c(401);` with 401 memo slots); SWC pipeline bails out and + returns the uncompiled source. The fixture exists to test that the + compiler handles many sequential reactive scopes without stack overflow, + so the SWC variant should compile. Root cause unclear β€” needs + investigation in the SWC pipeline or the compiler core to see where the + bail happens. + +- `pattern4_bare_type.js` β€” Two unrelated bugs in one fixture: + 1. Operator-precedence stripping. `Math.round((x - y) * 1000)` becomes + `Math.round(x - y * 1000)`. SWC codegen drops the parentheses around + the subtraction. Probably in `convert_ast_reverse.rs`'s + BinaryExpression handling. + 2. Method return type annotation. `formatMetrics(): Metrics` becomes + `formatMetrics()`. The TS-type-on-binding-ident fix in commit + cc1ba1e1 only covered binding identifiers; class method signatures + are a separate code path. Same shape of fix; different + `convert_binding_ident`-equivalent call site. + +- `reduce-reactive-deps/hoist-deps-diff-ssa-instance1.tsx` β€” + `(x as HasA).a.value + 2` becomes `(x as HasA.a.value) + 2`. The member + expression's property chain gets absorbed into the type annotation when + `convert_ast_reverse` emits the cast. Likely a parenthesization / + precedence bug in the reverse converter or the SWC printer's handling + of `TSAsExpression` as the object of a `MemberExpression`. + +- `todo-round2_unicode_string.js` (prefixed `todo-`) β€” Hex escape format + (`\xC5`) vs unicode escape (`\u00C5`) for bytes 0x80-0xFF. Both valid JS + literals; codegen format choice in SWC's string printer. + +- `todo-round3_promote_used_temps.js` (prefixed `todo-`) β€” Class body + codegen. TS emits the class with fields and constructor; SWC emits an + empty class body and pulls fields/methods out into separate assignments. + Likely an interaction between SWC codegen and the compiler's + `promote_used_temps` pass. + +- `ts-non-null-expression-default-value.tsx` β€” Generic type parameter + support. `const x: ReadonlyMap = ...` becomes + `const x = ...` (annotation dropped entirely). Our + `convert_ts_type_to_json` helper in cc1ba1e1 explicitly guards against + `TsTypeRef` with `type_params` to avoid silently emitting + `ReadonlyMap` without the params. The proper fix needs serialization of + `TSTypeParameterInstantiation` in `convert_ast.rs` AND deserialization + in `convert_ast_reverse.rs::convert_ts_type_from_json`. + +## Babel + +**TODO: scope this out.** Babel is at 1788 / 1795 (7 failures). These have +been the baseline throughout the SWC parity stack and were not touched, so the +failure list is whatever was on `pr-36173` before this work landed. + +Next step is to enumerate the failures by fixture and bucket them the same +way as SWC (fixture maintenance / external dependency / real bugs). Run: + +```bash +bash compiler/scripts/test-e2e.sh --no-color --variant babel +``` + +…and triage the resulting failures into A/B/C groups under this section. + +## OXC + +**TODO: scope this out.** OXC is at 1704 / 1795 (91 failures). The CLI +`filename` fix in commit c30f0d6f bumped this by +2 from the 1702 baseline, +but everything else is unaddressed. + +Next step is to enumerate failures and identify OXC-specific clusters +(likely AST conversion gaps in `react_compiler_oxc` analogous to the SWC +work in this stack). Run: + +```bash +bash compiler/scripts/test-e2e.sh --no-color --variant oxc +``` + +…and bucket the resulting failures into A/B/C groups under this section. +Expect significant overlap with the SWC Group C bugs (cast wrappers, +type annotations, UTF-16/WTF-8 handling) since both frontends share the +post-conversion pipeline. + +## How this stack got here + +- `compiler/scripts/test-e2e.sh --variant swc` baseline was 1742 / 1795 + (53 failures) before this stack. +- 9 commits in the current stack reduce that to 1780 / 1795 (15 failures, + -38 fixtures, 72% reduction). +- Babel variant: 1788 / 1795 throughout (no regressions). +- OXC variant: 1702 β†’ 1704 (the CLI filename commit also benefited OXC). +- `cargo test --workspace`: 56 passed, 0 failed throughout. From 5d235e3140010dfc72cca0ffd1d5751429cdbcd5 Mon Sep 17 00:00:00 2001 From: lauren Date: Thu, 21 May 2026 00:00:08 -0700 Subject: [PATCH 6/6] [rust-compiler] Bump round_trip test thread stack to 32 MiB in CI `scripts/test-babel-ast.sh` runs `cargo test -p react_compiler_ast --test round_trip`, which serde-walks the parsed Babel AST of every fixture in `__tests__/fixtures` and round-trips it through JSON. After the SWC location and TS-annotation fixes earlier in this stack, the script now parses 1780 / 1795 fixtures (up from 1720), and at least one of the newly-passing fixtures has deep enough JSX or expression nesting to overflow the default 8 MiB Rust thread stack on the Linux CI runner: thread 'round_trip_all_fixtures' (7109) has overflowed its stack fatal runtime error: stack overflow, aborting Set `RUST_MIN_STACK=33554432` (32 MiB) before invoking cargo test. `RUST_MIN_STACK` applies to threads spawned via `std::thread`, which is how the libtest harness runs each test, so this is sufficient without changing the test sources or rewriting the recursive serde visitor. The proper fix is to convert the recursive AST walk into an iterative one; this commit is the cheap unblock so the rest of the stack can land. Tracked in compiler/crates/TODO.md as future work (to be added once this lands). --- compiler/scripts/test-babel-ast.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/compiler/scripts/test-babel-ast.sh b/compiler/scripts/test-babel-ast.sh index 9b8acb95d5d..f2f23ff2c24 100755 --- a/compiler/scripts/test-babel-ast.sh +++ b/compiler/scripts/test-babel-ast.sh @@ -16,6 +16,13 @@ trap 'rm -rf "$TMPDIR"' EXIT echo "Parsing fixtures from $FIXTURE_SRC_DIR..." node "$REPO_ROOT/compiler/scripts/babel-ast-to-json.mjs" "$FIXTURE_SRC_DIR" "$TMPDIR" +# Bump the default 8 MiB Rust thread stack to 32 MiB. The round_trip test +# walks deeply-nested Babel AST fixtures via recursive serde Visitor; some +# fixtures exceed the default stack on Linux CI runners. RUST_MIN_STACK only +# affects threads spawned via std::thread (which is how the libtest harness +# runs each test), so this is enough without changing the test sources. +export RUST_MIN_STACK=33554432 + echo "Running round-trip test..." cd "$REPO_ROOT/compiler/crates" FIXTURE_JSON_DIR="$TMPDIR" ~/.cargo/bin/cargo test -p react_compiler_ast --test round_trip -- --nocapture