From dd0a02f393c8b9616b74cbbe9ad3e094827b3ddb Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Thu, 25 Jun 2026 12:38:40 -0500 Subject: [PATCH] fix: use exported name for namespaced di token references Aliased imports used as DI tokens emitted a namespace member access keyed by the local binding instead of the module's exported name. For `import { Foo as Bar } from "./m"` used as a constructor dependency, the compiler generated `i1.Bar` even though `m` only exports `Foo`, so the reference resolved to `undefined` at runtime and broke injection (the bundler also flags it as a missing export). Both namespaced-DI-reference paths used the local name: - factory deps and host directives in component/transform.rs (resolve_factory_dep_namespaces, resolve_host_directive_namespaces) - the component constructor-dep path in component/dependency.rs (create_token_expression) Carry the exported name through to both: transform.rs already had `imported_name` on its import info; dependency.rs gains a `token_imported_name` field populated from the import map in component/decorator.rs. A namespace member access now uses the exported name, falling back to the local name when they match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/component/decorator.rs | 3 ++ .../src/component/dependency.rs | 37 ++++++++++++++++++- .../src/component/transform.rs | 9 ++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 0ac348a0a..7bd5f83c3 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -989,6 +989,9 @@ fn extract_param_dependency<'a>( } else { let mut d = R3DependencyMetadata::new(token_name.clone()); d.token_source_module = Some(import_info.source_module.clone()); + // Carry the exported name so namespaced refs (`i1.X`) use the export + // name, not the local alias, for aliased imports. + d.token_imported_name = import_info.imported_name.clone(); // Always use namespace imports for DI tokens (has_named_import = false). // Import elision removes @Inject(TOKEN) argument imports since they're // only used in decorator positions that get compiled away. diff --git a/crates/oxc_angular_compiler/src/component/dependency.rs b/crates/oxc_angular_compiler/src/component/dependency.rs index 963ff7c8a..54d885e75 100644 --- a/crates/oxc_angular_compiler/src/component/dependency.rs +++ b/crates/oxc_angular_compiler/src/component/dependency.rs @@ -45,6 +45,12 @@ pub struct R3DependencyMetadata<'a> { /// `DIALOG_DATA` directly instead of `i1.DIALOG_DATA`. pub has_named_import: bool, + /// The module's exported name for this token, when it differs from the local + /// binding (i.e. an aliased import `import { Foo as Bar }` → `Some("Foo")`). + /// A namespace member access (`i1.X`) must use the export name, not the local + /// alias, or it resolves to `undefined` at runtime. + pub token_imported_name: Option>, + /// For `@Attribute()` dependencies, the attribute name. /// `None` for regular dependencies. pub attribute_name: Option>, @@ -78,6 +84,7 @@ impl<'a> R3DependencyMetadata<'a> { token: Some(token), token_source_module: None, has_named_import: false, + token_imported_name: None, attribute_name: None, host: false, optional: false, @@ -93,6 +100,7 @@ impl<'a> R3DependencyMetadata<'a> { token: None, token_source_module: None, has_named_import: false, + token_imported_name: None, attribute_name: None, host: false, optional: false, @@ -111,6 +119,7 @@ impl<'a> R3DependencyMetadata<'a> { token: None, token_source_module: None, has_named_import: false, + token_imported_name: None, attribute_name: None, host: false, optional: false, @@ -150,6 +159,7 @@ impl<'a> R3DependencyMetadata<'a> { token: Some(attribute_name.clone()), token_source_module: None, has_named_import: false, + token_imported_name: None, attribute_name: Some(attribute_name), host: false, optional: false, @@ -378,7 +388,10 @@ fn create_token_expression<'a>( )), allocator, ), - name: token_name.clone(), + // Namespace member access must use the module's exported name, not the + // local binding. For an aliased import `import { Foo as Bar }`, emit + // `i1.Foo` (not `i1.Bar`, which would be undefined at runtime). + name: dep.token_imported_name.clone().unwrap_or_else(|| token_name.clone()), optional: false, source_span: None, }, @@ -538,6 +551,28 @@ mod tests { assert!(!js.contains(",")); // No flags argument } + #[test] + fn test_aliased_import_uses_exported_name() { + // Regression: an aliased import used as a DI token, e.g. + // import { ExportedName as LocalAlias } from "@scope/pkg"; + // constructor(x: LocalAlias) {} + // must emit a namespace member access with the module's EXPORTED name + // (`i1.ExportedName`), not the local alias (`i1.LocalAlias`) which resolves + // to `undefined` at runtime and breaks injection. + let allocator = Allocator::default(); + let mut dep = R3DependencyMetadata::new(Ident::from("LocalAlias")); + dep.token_source_module = Some(Ident::from("@scope/pkg")); + dep.token_imported_name = Some(Ident::from("ExportedName")); + let mut registry = NamespaceRegistry::new(&allocator); + + let result = + compile_inject_dependency(&allocator, &dep, FactoryTarget::Component, 0, &mut registry); + let js = JsEmitter::new().emit_expression(&result); + + assert!(js.contains("ExportedName"), "should use exported name: {js}"); + assert!(!js.contains("LocalAlias"), "must not use local alias: {js}"); + } + #[test] fn test_optional_dependency() { let allocator = Allocator::default(); diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index b236048ec..5c5c164a8 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -651,7 +651,10 @@ fn resolve_factory_dep_namespaces<'a>( )), allocator, ), - name: name.clone(), + // Use the module's exported name, not the local binding: a namespace + // member access (`i1.X`) must reference the export name. For an aliased + // import `import { Foo as Bar }`, `imported_name` is `Some("Foo")`. + name: import_info.imported_name.clone().unwrap_or_else(|| name.clone()), optional: false, source_span: None, }, @@ -688,7 +691,9 @@ fn resolve_host_directive_namespaces<'a>( )), allocator, ), - name: name.clone(), + // Use the module's exported name, not the local binding (see + // resolve_factory_dep_namespaces): `i1.X` must use the export name. + name: import_info.imported_name.clone().unwrap_or_else(|| name.clone()), optional: false, source_span: None, },