Skip to content
Merged
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
8 changes: 6 additions & 2 deletions crates/oxc_angular_compiler/src/class_metadata/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -819,7 +823,7 @@ fn build_param_type_expression<'a>(
)),
&allocator,
),
name: tn.clone(),
name,
optional: false,
source_span: None,
},
Expand Down
3 changes: 3 additions & 0 deletions crates/oxc_angular_compiler/src/component/decorator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
Brooooooklyn marked this conversation as resolved.
// 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.
Expand Down
30 changes: 29 additions & 1 deletion crates/oxc_angular_compiler/src/component/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ident<'a>>,

/// For `@Attribute()` dependencies, the attribute name.
/// `None` for regular dependencies.
pub attribute_name: Option<Ident<'a>>,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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();
Expand Down
119 changes: 113 additions & 6 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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()),
Comment thread
Brooooooklyn marked this conversation as resolved.
optional: false,
source_span: None,
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
Expand Down
Loading