Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/fix-react-compiler-spans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
swc_core: patch
swc_ecma_react_compiler: patch
---

fix(es/react-compiler): Recover spans from loc for compiler-generated nodes
77 changes: 72 additions & 5 deletions crates/swc_ecma_react_compiler/src/convert_ast_reverse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

//! Reverse AST converter: `react_compiler_ast` (Babel format) to SWC AST.

use std::cell::RefCell;
use std::cell::{Cell, RefCell};

use react_compiler_ast::{
common::{BaseNode, RawNode},
Expand All @@ -18,27 +18,51 @@ use react_compiler_ast::{
statements::*,
File,
};
use rustc_hash::FxHashSet;
use serde_json::Value;
use swc_atoms::Atom;
use swc_common::{BytePos, Span, Spanned, SyntaxContext, DUMMY_SP};
use swc_ecma_ast as swc;

use crate::preserved_ast::PreservedAst;

/// Result of converting a React Compiler AST back to SWC.
pub struct ReverseConversion {
pub program: swc::Program,
/// `span.lo` of identifiers whose spans were recovered from `loc` because
/// the node was regenerated by the compiler (no `start`/`end`). These
/// spans exist only for source mapping and must not participate in the
/// `span.lo`-keyed rename pass: compiled functions already carry the
/// renamed identifiers.
pub recovered_ident_spans: FxHashSet<u32>,
}

/// Convert with source text and preserved SWC nodes from the forward pass.
pub fn convert_program_to_swc(file: &File, preserved_ast: PreservedAst) -> swc::Program {
pub fn convert_program_to_swc(file: &File, preserved_ast: PreservedAst) -> ReverseConversion {
let ctx = ReverseCtx::new(preserved_ast);
ctx.convert_program(&file.program)
let program = ctx.convert_program(&file.program);
ReverseConversion {
program,
recovered_ident_spans: ctx.recovered_ident_spans.into_inner(),
}
}

struct ReverseCtx {
preserved_ast: RefCell<PreservedAst>,
/// Offset added to 0-based `loc` indices to rebase them into the source
/// file's `BytePos` range. Derived in `convert_program` from the root
/// node, which carries both the absolute `start` and the file-relative
/// `loc`; defaults to 1 (a file starting at `BytePos(1)`).
loc_base: Cell<u32>,
recovered_ident_spans: RefCell<FxHashSet<u32>>,
}

impl ReverseCtx {
fn new(preserved_ast: PreservedAst) -> Self {
Self {
preserved_ast: RefCell::new(preserved_ast),
loc_base: Cell::new(1),
recovered_ident_spans: RefCell::new(FxHashSet::default()),
}
}

Expand Down Expand Up @@ -106,13 +130,44 @@ impl ReverseCtx {
match (base.start, base.end) {
(Some(start), Some(end)) => Span::new(BytePos(start), BytePos(end)),
(Some(start), None) => Span::new(BytePos(start), BytePos(start)),
_ => self.span_from_loc(base),
}
}

/// Nodes regenerated by the React Compiler carry only a Babel-style `loc`
/// (with 0-based `index` offsets), not the `start`/`end` offsets the
/// forward conversion emits. Recover the span from `loc` so codegen can
/// still emit source mappings for compiled function bodies.
fn span_from_loc(&self, base: &BaseNode) -> Span {
let Some(loc) = &base.loc else {
return DUMMY_SP;
};
let loc_base = self.loc_base.get();
match (loc.start.index, loc.end.index) {
(Some(start), Some(end)) => {
Span::new(BytePos(start + loc_base), BytePos(end + loc_base))
}
(Some(start), None) => {
let pos = BytePos(start + loc_base);
Span::new(pos, pos)
}
_ => DUMMY_SP,
}
}

// ===== Program =====

fn convert_program(&self, program: &react_compiler_ast::Program) -> swc::Program {
// The root node carries both the absolute `start` and the
// file-relative 0-based `loc.index`; their difference rebases
// loc-recovered spans into the current source file's BytePos range.
if let (Some(start), Some(index)) = (
program.base.start,
program.base.loc.as_ref().and_then(|loc| loc.start.index),
) {
self.loc_base.set(start.saturating_sub(index).max(1));
Comment on lines +164 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebase loc spans from the SourceFile start

Fresh evidence in this revision: this new base is derived from program.base.loc, but the forward converter’s position() still clamps absolute BytePos values against the current fm.src.len(), so a shared SourceMap file after the first gets a root loc.index of the file length instead of 0. In that common process_js_file path, loc-only compiler-generated nodes are shifted by start - len (or collapse to the file start) rather than the current SourceFile start, so recovered spans/source maps and span-keyed preserved TS metadata can point outside the file or to the wrong node; use the actual source-file start position instead of trusting the root loc index.

Useful? React with 👍 / 👎.

}

let mut body = self.convert_statement_list_with_spans(&program.body);
let directives = self.convert_directive_list(&program.directives);
if !directives.is_empty() {
Expand Down Expand Up @@ -2032,7 +2087,7 @@ impl ReverseCtx {

fn convert_identifier(&self, id: &Identifier) -> swc::Ident {
swc::Ident {
span: self.span_from_base(&id.base),
span: self.ident_span(&id.base),
ctxt: SyntaxContext::empty(),
sym: self.atom(&id.name),
optional: id.optional.unwrap_or(false),
Expand All @@ -2041,13 +2096,25 @@ impl ReverseCtx {

fn convert_jsx_identifier(&self, id: &JSXIdentifier) -> swc::Ident {
swc::Ident {
span: self.span_from_base(&id.base),
span: self.ident_span(&id.base),
ctxt: SyntaxContext::empty(),
sym: self.atom(&id.name),
optional: false,
}
}

/// Identifier spans recovered from `loc` are recorded so the rename pass
/// can ignore them: they may coincide with the `span.lo` of an original
/// reference to a renamed binding, and rewriting a compiler-generated
/// identifier there would corrupt the compiled output.
fn ident_span(&self, base: &BaseNode) -> Span {
let span = self.span_from_base(base);
if base.start.is_none() && span != DUMMY_SP {
self.recovered_ident_spans.borrow_mut().insert(span.lo.0);
}
span
}

fn make_ident_name(&self, base: &BaseNode, name: &str) -> swc::IdentName {
swc::IdentName::new(self.atom(name), self.span_from_base(base))
}
Expand Down
6 changes: 4 additions & 2 deletions crates/swc_ecma_react_compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ pub fn transform(
}
};

let rename_plan = build_rename_plan(&scope_info, &renames);
let mut rename_plan = build_rename_plan(&scope_info, &renames);
let program = file.map(|file: react_compiler_ast::File| {
let mut compiled = convert_program_to_swc(&file, preserved_ast);
let conversion = convert_program_to_swc(&file, preserved_ast);
let mut compiled = conversion.program;
rename_plan.retain(|position, _| !conversion.recovered_ident_spans.contains(position));
apply_renames(&mut compiled, &rename_plan);
compiled
});
Expand Down
65 changes: 63 additions & 2 deletions crates/swc_ecma_react_compiler/src/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::{
};

fn convert_program_to_swc(file: &File) -> swc_ecma_ast::Program {
convert_program_to_swc_with_preserved_ast(file, Default::default())
convert_program_to_swc_with_preserved_ast(file, Default::default()).program
}

fn convert_module(module: &swc_ecma_ast::Module, source_text: &str) -> File {
Expand Down Expand Up @@ -1227,6 +1227,67 @@ fn transform_component_with_hook_does_not_panic() {
let _ = result.diagnostics;
}

#[test]
fn transform_preserves_spans_in_compiled_function_body() {
use swc_common::{Spanned, DUMMY_SP};
use swc_ecma_visit::{Visit, VisitWith};

struct SpanCounter {
real: usize,
}

impl Visit for SpanCounter {
fn visit_stmt(&mut self, stmt: &swc_ecma_ast::Stmt) {
if stmt.span() != DUMMY_SP {
self.real += 1;
}
stmt.visit_children_with(self);
}
}

let source = r#"
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const doubled = count * 2;
return <div>{doubled}</div>;
}
"#;
let result = transform_source(
source,
Syntax::Es(EsSyntax {
jsx: true,
..Default::default()
}),
default_options(),
);
let program = result
.program
.expect("component with hooks should be compiled");
let module = match &program {
swc_ecma_ast::Program::Module(module) => module,
swc_ecma_ast::Program::Script(_) => panic!("expected module output"),
};

let body = module
.body
.iter()
.find_map(|item| match item {
swc_ecma_ast::ModuleItem::Stmt(swc_ecma_ast::Stmt::Decl(swc_ecma_ast::Decl::Fn(
decl,
))) if decl.ident.sym == "Counter" => decl.function.body.as_ref(),
_ => None,
})
.expect("compiled Counter function should have a body");

let mut counter = SpanCounter { real: 0 };
body.visit_with(&mut counter);
assert!(
counter.real > 0,
"compiled function body should keep real spans so codegen can emit source mappings"
);
}

#[test]
fn transform_ref_access_error_is_not_swc_diagnostic_with_default_panic_threshold() {
let source = r#"
Expand Down Expand Up @@ -1438,7 +1499,7 @@ fn convert_ts_source(source: &str) -> crate::convert_ast::ConvertResult {
fn round_trip_convert_result(result: crate::convert_ast::ConvertResult) -> swc_ecma_ast::Program {
assert_file_serializes_to_json(&result.file);

convert_program_to_swc_with_preserved_ast(&result.file, result.preserved_ast)
convert_program_to_swc_with_preserved_ast(&result.file, result.preserved_ast).program
}

/// TS module-interop statements (`import x = require(...)`, `export = x`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function App<T extends string = "on">(x: T): React.ReactElement {
}
let t4;
if ($[4] !== x) {
t4 = <section data-id={dashboard.id} {...panelProps}><UI.Panel x={x} title={`Status: ${Status.Ready}`}>{t1}{t2}{t3}{dashboard.actions}</UI.Panel></section>;
t4 = <section data-id={dashboard.id} {...panelProps}><UI.Panel<T> x={x} title={`Status: ${Status.Ready}`}>{t1}{t2}{t3}{dashboard.actions}</UI.Panel></section>;
$[4] = x;
$[5] = t4;
} else {
Expand Down
Loading