mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user