Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions compiler/crates/TODO.md
Original file line number Diff line number Diff line change
@@ -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: <fbt> 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<string, string> = ...` 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.
6 changes: 4 additions & 2 deletions compiler/crates/react_compiler_e2e_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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.
Expand Down
133 changes: 133 additions & 0 deletions compiler/crates/react_compiler_swc/src/apply_renames.rs
Original file line number Diff line number Diff line change
@@ -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<u32, &BindingRenameInfo> = renames
.iter()
.map(|rename| (rename.declaration_start, rename))
.collect();
let mut renamed_bindings: HashMap<BindingId, String> = 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<u32, String> = 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<u32, String>,
}

impl RenameApplyVisitor {
fn renamed_at(&self, position: u32) -> Option<String> {
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),
}
}
}
20 changes: 16 additions & 4 deletions compiler/crates/react_compiler_swc/src/convert_ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,27 @@ struct ConvertCtx<'a> {
#[allow(dead_code)]
source_text: &'a str,
line_offsets: Vec<u32>,
utf16_offsets: Vec<u32>,
}

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,
}
}

Expand All @@ -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);
Expand All @@ -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),
}
}

Expand Down
Loading
Loading