fix(react_compiler): keep imports referenced only by a local re-export (#23176)

## Problem

When the React Compiler compiles a module, a named import that is only re-exported via a local `export { … }` clause was dropped from the output, leaving a dangling `export { Foo }` that references a removed binding — i.e. **invalid code**.

Minimal repro:

```tsx
import { Foo } from "./foo";
export { Foo };
export function C() { const x = useState(0); return <div>{x}</div>; }
```

Before this change, the compiled output emits `export { Foo }` with **no** `import { Foo }`. With the React Compiler disabled the import is kept, so the bug is specific to the compiler's AST round-trip.

## Root cause

oxc's parser rewrites a *local* export's specifier `local` from `IdentifierName` to `IdentifierReference` when there is no `source` (`parse_export_named_specifiers`), and semantic analysis only records a reference to the imported binding for an `IdentifierReference` (`binder.rs`). The React Compiler's reverse AST conversion (`convert_ast_reverse`) always rebuilt `local` as a plain `IdentifierName`, so no reference was created, and the downstream TypeScript import elision then removed the "unused" import — while leaving the export clause behind.

## Fix

Mirror the parser: in `convert_export_named_declaration`, when the export has no `source`, build each specifier's `local` as an `IdentifierReference`. Re-exports (`export { x } from "…"`) keep an `IdentifierName`, since there `local` names an export of the other module.

## Verification

- Added regression test `local_reexport_keeps_its_import_binding`; `cargo test -p oxc_react_compiler` passes (17/17), `napi/transform` passes (62/62).
- Found by differential-testing the React Compiler against `babel-plugin-react-compiler` across the ecosystem-ci repos. Re-running the 90 affected files with the fix: the invalid-output bucket drops to **0** and 84/90 now match Babel exactly (the rest are pre-existing, benign output-equivalent differences).

## Note

The upstream `oxc-react-compiler` repo carries an identical copy of this conversion code (`crates/react_compiler_oxc/src/convert_ast_reverse.rs`); the same change should be mirrored there to survive the next vendor sync.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Boshen committed 2026-06-09 17:29:42 +00:00
1 parent d7038de45c
commit de38a3f892
2 files changed
+81 -4

No files matched your search

@@ -2829,14 +2829,42 @@ impl<'a> ReverseCtx<'a> {
}
}
/// Like [`Self::convert_module_export_name`], but builds an identifier `local`
/// of a local export specifier as an `IdentifierReference` (not a bare
/// `IdentifierName`) so semantic analysis links it to the exported binding. A
/// string-literal local is only valid with a `source`, so it falls back to the
/// plain name.
fn convert_module_export_name_local_ref(
&self,
name: &react_compiler_ast::declarations::ModuleExportName,
) -> oxc::ModuleExportName<'a> {
match name {
react_compiler_ast::declarations::ModuleExportName::Identifier(id) => {
oxc::ModuleExportName::IdentifierReference(
self.builder.identifier_reference(SPAN, self.atom(&id.name)),
)
}
react_compiler_ast::declarations::ModuleExportName::StringLiteral(_) => {
self.convert_module_export_name(name)
}
}
}
fn convert_export_named_declaration(
&self,
decl: &ExportNamedDeclaration,
) -> oxc::ExportNamedDeclaration<'a> {
let declaration = decl.declaration.as_ref().map(|d| self.convert_declaration(d));
let specifiers = self
.builder
.vec_from_iter(decl.specifiers.iter().map(|s| self.convert_export_specifier(s)));
// For a local export (`export { x }`, no `source`) the specifier `local`
// refers to a binding in this module, so it must be an `IdentifierReference`
// for semantic analysis to link it (and thus keep its import alive through
// TypeScript import elision). Re-exports (`export { x } from`) keep an
// `IdentifierName`, since `local` names an export of the other module. This
// mirrors the parser (`parse_export_named_specifiers`).
let local_is_reference = decl.source.is_none();
let specifiers = self.builder.vec_from_iter(
decl.specifiers.iter().map(|s| self.convert_export_specifier(s, local_is_reference)),
);
let source = decl
.source
.as_ref()
@@ -2886,10 +2914,15 @@ impl<'a> ReverseCtx<'a> {
fn convert_export_specifier(
&self,
spec: &react_compiler_ast::declarations::ExportSpecifier,
local_is_reference: bool,
) -> oxc::ExportSpecifier<'a> {
match spec {
react_compiler_ast::declarations::ExportSpecifier::ExportSpecifier(s) => {
let local = self.convert_module_export_name(&s.local);
let local = if local_is_reference {
self.convert_module_export_name_local_ref(&s.local)
} else {
self.convert_module_export_name(&s.local)
};
let exported = self.convert_module_export_name(&s.exported);
let export_kind = match s.export_kind.as_ref() {
Some(ExportKind::Type) => oxc::ImportOrExportKind::Type,
+44
View File
@@ -573,6 +573,50 @@ function Component(props) {\n return <div>{E.A}{N.value}{props.text}</div>;\n}\
);
}
/// A local `export { x }` that re-exports an imported binding must keep its
/// `local` as an `IdentifierReference` after the round-trip, so semantic
/// analysis links it to the import and downstream TypeScript import elision
/// keeps the import alive instead of leaving a dangling export.
#[test]
fn local_reexport_keeps_its_import_binding() {
use oxc_ast::ast::{ModuleExportName, Statement};
let source = "\
import { Foo } from './foo';\n\
export { Foo };\n\
function Component(props) {\n return <div>{props.text}</div>;\n}\n";
let allocator = oxc_allocator::Allocator::default();
let result = transform_source(source, oxc_span::SourceType::tsx(), &allocator, options());
let program = result.program.expect("component should be compiled");
let export = program
.body
.iter()
.find_map(|stmt| match stmt {
Statement::ExportNamedDeclaration(decl) if decl.source.is_none() => Some(decl),
_ => None,
})
.expect("a local `export { Foo }` should round-trip");
let local = &export.specifiers.first().expect("export specifier").local;
assert!(
matches!(local, ModuleExportName::IdentifierReference(_)),
"local export `local` must be an IdentifierReference so semantic links it to the import",
);
// The freshly-built scoping for the compiled program must record the
// export's reference to the import, or import elision would drop it.
let semantic = oxc_semantic::SemanticBuilder::new().build(&program).semantic;
let scoping = semantic.scoping();
let foo = scoping
.symbol_ids()
.find(|&id| scoping.symbol_name(id) == "Foo")
.expect("`Foo` import binding should exist");
assert!(
scoping.get_resolved_references(foo).next().is_some(),
"the local re-export must reference the `Foo` import binding",
);
}
/// A `React.memo(...)` component is anonymous; the prefilter must still see it.
#[test]
fn memo_wrapped_component_compiles() {