diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index d0096b91d..120427316 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -775,7 +775,9 @@ fn build_param_type_expression<'a>( type_name.as_ref().is_some_and(|tn| tn.as_str() == token.as_str()); if type_matches_token { - let name = type_name.unwrap_or_else(|| token.clone()); + // `import { Foo as Bar }` + `constructor(x: Bar)` → `i1.Foo`. + let local_name = type_name.unwrap_or_else(|| token.clone()); + let name = dep.token_imported_name.clone().unwrap_or_else(|| local_name); let namespace = namespace_registry.get_or_assign(source_module); return Some(OutputExpression::ReadProp(Box::new_in( ReadPropExpr { @@ -810,6 +812,8 @@ fn build_param_type_expression<'a>( return None; } let namespace = namespace_registry.get_or_assign(&import_info.source_module); + // Prefer export name over local alias for namespace property access. + let name = import_info.imported_name.clone().unwrap_or_else(|| tn.clone()); return Some(OutputExpression::ReadProp(Box::new_in( ReadPropExpr { receiver: Box::new_in( @@ -819,7 +823,7 @@ fn build_param_type_expression<'a>( )), &allocator, ), - name: tn.clone(), + name, optional: false, source_span: None, }, diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 05470c73d..8936250a3 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 a006f6a78..db7bdbde5 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,8 @@ fn create_token_expression<'a>( )), &allocator, ), - name: token_name.clone(), + // Export name when aliased (`Foo as Bar` → `i1.Foo`). + name: dep.token_imported_name.clone().unwrap_or_else(|| token_name.clone()), optional: false, source_span: None, }, @@ -538,6 +549,23 @@ mod tests { assert!(!js.contains(",")); // No flags argument } + #[test] + fn test_aliased_import_uses_exported_name() { + // Aliased DI token must use export name on the namespace object. + 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 ae02fd4fc..772fed993 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -524,10 +524,10 @@ pub fn build_import_map<'a>( .map(|resolved| Ident::from(allocator.alloc_str(resolved))) .unwrap_or_else(|| default_source_module.clone().into()); - // Capture the original exported name when it differs from the - // local binding (i.e., `import { Foo as Bar }`). This is used - // when building `@defer` dependency resolvers so the dynamic - // import chain references the original export, not the alias. + // `import { Foo as Bar }` → export name `Foo` for `i1.Foo` / @defer. + // Path rewrite via `resolved_imports` does not change the export + // name on the target (still `Foo`); only an extended API could + // supply a different target export after a barrel rename. let imported_export_name = module_export_name_to_str(&spec.imported); let imported_name = imported_export_name .filter(|exported| *exported != local_name.as_str()) @@ -651,7 +651,8 @@ fn resolve_factory_dep_namespaces<'a>( )), &allocator, ), - name: name.clone(), + // Export name when aliased (`Foo as Bar` → `i1.Foo`). + name: import_info.imported_name.clone().unwrap_or_else(|| name.clone()), optional: false, source_span: None, }, @@ -688,7 +689,8 @@ fn resolve_host_directive_namespaces<'a>( )), &allocator, ), - name: name.clone(), + // Export name when aliased (`Foo as Bar` → `i1.Foo`). + name: import_info.imported_name.clone().unwrap_or_else(|| name.clone()), optional: false, source_span: None, }, @@ -6845,6 +6847,111 @@ export class TestComponent { ); } + #[test] + fn test_aliased_import_di_token_uses_exported_name_in_factory_and_ctor_params() { + // Aliased DI token: factory inject and ctorParameters both use the export + // name (`i1.ExportedName`), never the local alias. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; +import { ExportedName as LocalAlias } from './svc'; + +@Component({ + selector: 'app-x', + template: '', + standalone: true, +}) +export class X { + constructor(x: LocalAlias) {} +} +"#; + + let mut options = TransformOptions::default(); + options.emit_class_metadata = true; + + let result = + transform_angular_file(&allocator, "x.component.ts", source, Some(&options), None); + + assert!(!result.has_errors(), "Transform should not have errors: {:?}", result.diagnostics); + + // Namespace import for the service module + assert!( + result.code.contains("import * as i1 from './svc'"), + "Should generate namespace import for './svc', but got:\n{}", + result.code + ); + + // Factory inject AND setClassMetadata ctorParameters must use the export name + assert!( + result.code.contains("i1.ExportedName"), + "Factory and/or ctorParameters should use i1.ExportedName, but got:\n{}", + result.code + ); + assert!( + !result.code.contains("i1.LocalAlias"), + "Must not emit i1.LocalAlias (undefined on namespace); got:\n{}", + result.code + ); + + // setClassMetadata ctorParameters type field specifically + let ctor_params_ok = result.code.contains("type: i1.ExportedName") + || result.code.contains("type:i1.ExportedName"); + assert!( + ctor_params_ok, + "setClassMetadata ctorParameters should use type: i1.ExportedName, but got:\n{}", + result.code + ); + } + + /// Path rewrite keeps the specifier export name: `Foo as Bar` + resolve to + /// `./pkg` still emits `i1.Foo` (target exports `Foo`, not `Bar`). + #[test] + fn test_resolved_imports_path_only_keeps_export_name() { + use std::collections::HashMap; + + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; +import { Foo as Bar } from '@pkg'; + +@Component({ + selector: 'app-x', + template: '', + standalone: true, +}) +export class X { + constructor(x: Bar) {} +} +"#; + + let mut resolved = HashMap::new(); + resolved.insert("Bar".to_string(), "./pkg".to_string()); + + let mut options = TransformOptions::default(); + options.emit_class_metadata = true; + options.resolved_imports = Some(resolved); + + let result = + transform_angular_file(&allocator, "x.component.ts", source, Some(&options), None); + + assert!(!result.has_errors(), "errors: {:?}", result.diagnostics); + assert!( + result.code.contains("import * as i1 from './pkg'"), + "namespace import should use resolved path; got:\n{}", + result.code + ); + assert!( + result.code.contains("i1.Foo"), + "should keep export name Foo after path rewrite; got:\n{}", + result.code + ); + assert!( + !result.code.contains("i1.Bar"), + "must not use local alias Bar on namespace; got:\n{}", + result.code + ); + } + #[test] fn test_directive_factory_deps_get_correct_namespace_resolution() { // Regression test for bug where resolve_factory_dep_namespaces() was NOT called