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
96 changes: 92 additions & 4 deletions crates/oxc_angular_compiler/src/class_metadata/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use oxc_ast::ast::{
};
use oxc_span::Atom;

use crate::component::{NamespaceRegistry, R3DependencyMetadata};
use crate::output::ast::{
ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry,
LiteralMapExpr, LiteralValue, OutputExpression, ReadPropExpr, ReadVarExpr,
Expand Down Expand Up @@ -110,9 +111,16 @@ pub fn build_decorator_metadata_array<'a>(
///
/// Creates: `() => [{ type: SomeService, decorators: [...] }, ...]`
/// Returns `None` if the class has no constructor.
///
/// For imported types, generates namespace-prefixed references (e.g., `i1.SomeService`)
/// using the constructor dependency metadata and namespace registry. This matches
/// Angular's behavior where type-only imports need namespace imports because
/// TypeScript types are erased at runtime.
pub fn build_ctor_params_metadata<'a>(
allocator: &'a Allocator,
class: &Class<'a>,
constructor_deps: Option<&[R3DependencyMetadata<'a>]>,
namespace_registry: &mut NamespaceRegistry<'a>,
) -> Option<OutputExpression<'a>> {
// Find constructor
let constructor = class.body.body.iter().find_map(|element| {
Expand All @@ -126,11 +134,18 @@ pub fn build_ctor_params_metadata<'a>(

let mut param_entries = AllocVec::new_in(allocator);

for param in constructor {
for (i, param) in constructor.iter().enumerate() {
let mut map_entries = AllocVec::new_in(allocator);

// Extract type from TypeScript type annotation
let type_expr = extract_param_type_expression(allocator, param).unwrap_or_else(|| {
// Extract type from TypeScript type annotation, using namespace-prefixed
// references for imported types when constructor dependency info is available.
let type_expr = build_param_type_expression(
allocator,
param,
constructor_deps.and_then(|deps| deps.get(i)),
namespace_registry,
)
.unwrap_or_else(|| {
OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::Undefined, source_span: None },
allocator,
Expand Down Expand Up @@ -257,7 +272,80 @@ pub fn build_prop_decorators_metadata<'a>(
// Internal helper functions
// ============================================================================

/// Extract the type expression from a constructor parameter.
/// Build the type expression for a constructor parameter, using namespace-prefixed
/// references for imported types.
///
/// TypeScript type annotations are erased at runtime, so imported types need namespace
/// imports (e.g., `i1.SomeService`) to be available as runtime values.
///
/// The `dep.token_source_module` tracks where the injection token comes from. We only
/// use it for namespace prefix when the type annotation name matches the dep token name,
/// confirming that the dep's source module applies to the type. When they differ
/// (e.g., `@Inject(DOCUMENT) doc: Document`), we fall back to bare name since the type
/// may be a global or from a different module.
fn build_param_type_expression<'a>(
allocator: &'a Allocator,
param: &FormalParameter<'a>,
dep: Option<&R3DependencyMetadata<'a>>,
namespace_registry: &mut NamespaceRegistry<'a>,
) -> Option<OutputExpression<'a>> {
// Extract the type name from the type annotation
let type_name = extract_param_type_name(param);

// Use namespace prefix when the type annotation matches the dep token name
// and the dep has a source module (imported type).
if let Some(dep) = dep {
if let Some(ref source_module) = dep.token_source_module {
if let Some(ref token) = dep.token {
let type_matches_token =
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());
let namespace = namespace_registry.get_or_assign(source_module);
return Some(OutputExpression::ReadProp(Box::new_in(
ReadPropExpr {
receiver: Box::new_in(
OutputExpression::ReadVar(Box::new_in(
ReadVarExpr { name: namespace, source_span: None },
allocator,
)),
allocator,
),
name,
optional: false,
source_span: None,
},
allocator,
)));
}
}
}
}

// Fall back to extracting the bare type name from the type annotation
extract_param_type_expression(allocator, param)
}

/// Extract the type name (as an Atom) from a constructor parameter's type annotation.
///
/// Returns the simple type name from the annotation, if present.
/// Used to get the type name for namespace-prefixed references in metadata.
fn extract_param_type_name<'a>(param: &FormalParameter<'a>) -> Option<Atom<'a>> {
let type_annotation = param.type_annotation.as_ref()?;
match &type_annotation.type_annotation {
TSType::TSTypeReference(type_ref) => match &type_ref.type_name {
TSTypeName::IdentifierReference(id) => Some(id.name),
TSTypeName::QualifiedName(qualified) => Some(qualified.right.name),
TSTypeName::ThisExpression(_) => None,
},
_ => None,
}
}

/// Extract the type expression from a constructor parameter's type annotation.
///
/// This is the fallback path for local types that don't need namespace prefixes.
fn extract_param_type_expression<'a>(
allocator: &'a Allocator,
param: &FormalParameter<'a>,
Expand Down
10 changes: 9 additions & 1 deletion crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -779,14 +779,22 @@ pub fn transform_angular_file(
);

// Build metadata from the class AST
// Pass constructor deps and namespace registry so that
// imported types get namespace-prefixed references
// (e.g., i1.SomeService instead of bare SomeService)
let ctor_deps_slice =
metadata.constructor_deps.as_ref().map(|v| v.as_slice());
let class_metadata = R3ClassMetadata {
r#type: type_expr,
decorators: build_decorator_metadata_array(
allocator,
&[decorator],
),
ctor_parameters: build_ctor_params_metadata(
allocator, class,
allocator,
class,
ctor_deps_slice,
&mut file_namespace_registry,
),
prop_decorators: build_prop_decorators_metadata(
allocator, class,
Expand Down
8 changes: 4 additions & 4 deletions crates/oxc_angular_compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ pub use transform::{HtmlToR3Transform, html_to_r3::html_ast_to_r3_ast};
pub use component::{
AngularVersion, ChangeDetectionStrategy, CompiledComponent, ComponentMetadata,
HmrTemplateCompileOutput, HostMetadata, HostMetadataInput, ImportInfo, ImportMap,
ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult, ViewEncapsulation,
build_import_map, compile_component_template, compile_for_hmr, compile_template_for_hmr,
compile_template_to_js, compile_template_to_js_with_options, extract_component_metadata,
transform_angular_file,
NamespaceRegistry, ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult,
ViewEncapsulation, build_import_map, compile_component_template, compile_for_hmr,
compile_template_for_hmr, compile_template_to_js, compile_template_to_js_with_options,
extract_component_metadata, transform_angular_file,
};

// Re-export cross-file elision types when feature is enabled
Expand Down
163 changes: 163 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3977,3 +3977,166 @@ fn test_for_index_xref_with_i18n_attribute_binding() {
// matching Angular TS which stores direct i18n.Message object references on BindingOp.
insta::assert_snapshot!("for_index_xref_with_i18n_attribute_binding", js);
}

/// Tests that setClassMetadata uses namespace-prefixed type references for imported
/// constructor parameter types.
///
/// Angular's TypeScript compiler distinguishes between local and imported types in
/// the ɵsetClassMetadata constructor parameter metadata:
/// - Local types use bare names: `{ type: LocalService }`
/// - Imported types use namespace-prefixed names: `{ type: i1.ImportedService }`
///
/// This is because TypeScript type annotations are erased at runtime, so imported
/// types need namespace imports (i0, i1, i2...) to be available as runtime values.
/// The factory function (ɵfac) already handles this correctly via R3DependencyMetadata
/// and create_token_expression, but setClassMetadata was using bare names for all types.
#[test]
fn test_set_class_metadata_uses_namespace_for_imported_ctor_params() {
let allocator = Allocator::default();
let source = r#"
import { Component } from '@angular/core';
import { SomeService } from './some.service';

@Component({
selector: 'test-comp',
template: '<div>hello</div>',
standalone: true,
})
export class TestComponent {
constructor(private svc: SomeService) {}
}
"#;

let options = ComponentTransformOptions {
emit_class_metadata: true,
..ComponentTransformOptions::default()
};

let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);

assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

// Extract the setClassMetadata section specifically (not the factory function)
let metadata_section = result
.code
.split("ɵsetClassMetadata")
.nth(1)
.expect("setClassMetadata should be present in output");

// The ctor_parameters callback should use namespace-prefixed type for
// the imported SomeService: `{type:i1.SomeService}` not `{type:SomeService}`
assert!(
metadata_section.contains("i1.SomeService"),
"setClassMetadata ctor_parameters should use namespace-prefixed type (i1.SomeService) for imported constructor parameter. Metadata section:\n{}",
metadata_section
);
assert!(
!metadata_section.contains("type:SomeService}"),
"setClassMetadata should NOT use bare type name for imported types. Metadata section:\n{}",
metadata_section
);
}

/// Tests that setClassMetadata uses namespace-prefixed type even when @Inject is present.
///
/// When a constructor parameter has both a type annotation and @Inject decorator pointing
/// to the same imported class, the metadata `type` field should still use namespace prefix.
/// The factory correctly uses bare names for @Inject tokens with named imports, but the
/// metadata type always represents the TypeScript type annotation which is erased at runtime.
///
/// Example:
/// - Factory: `ɵɵdirectiveInject(TagPickerComponent, 12)` (bare - ok, @Inject value import)
/// - Metadata: `{ type: i1.TagPickerComponent, decorators: [{type: Inject, ...}] }` (namespace)
#[test]
fn test_set_class_metadata_namespace_with_inject_decorator() {
let allocator = Allocator::default();
let source = r#"
import { Component, Inject, Optional, SkipSelf } from '@angular/core';
import { SomeService } from './some.service';

@Component({
selector: 'test-comp',
template: '<div>hello</div>',
standalone: true,
})
export class TestComponent {
constructor(
@Optional() @SkipSelf() @Inject(SomeService) private svc: SomeService
) {}
}
"#;

let options = ComponentTransformOptions {
emit_class_metadata: true,
..ComponentTransformOptions::default()
};

let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);

assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

// Extract the setClassMetadata section
let metadata_section = result
.code
.split("ɵsetClassMetadata")
.nth(1)
.expect("setClassMetadata should be present in output");

// Even with @Inject(SomeService), the type field should use namespace prefix
// because the type annotation is erased by TypeScript
assert!(
metadata_section.contains("i1.SomeService"),
"setClassMetadata should use namespace-prefixed type even with @Inject. Metadata section:\n{}",
metadata_section
);
}

/// Tests that when @Inject token differs from the type annotation (e.g., @Inject(DOCUMENT)
/// on a parameter typed as Document), the metadata type uses bare name since the type
/// annotation may reference a global or different module than the injection token.
#[test]
fn test_set_class_metadata_inject_differs_from_type() {
let allocator = Allocator::default();
let source = r#"
import { Component, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

@Component({
selector: 'test-comp',
template: '<div>hello</div>',
standalone: true,
})
export class TestComponent {
constructor(@Inject(DOCUMENT) private doc: Document) {}
}
"#;

let options = ComponentTransformOptions {
emit_class_metadata: true,
..ComponentTransformOptions::default()
};

let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);

assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

let metadata_section = result
.code
.split("ɵsetClassMetadata")
.nth(1)
.expect("setClassMetadata should be present in output");

// The type should be bare "Document" (global type), not namespace-prefixed
// even though the @Inject token (DOCUMENT) is from @angular/common
assert!(
metadata_section.contains("type:Document"),
"setClassMetadata should use bare type for globals when @Inject token differs. Metadata section:\n{}",
metadata_section
);
// Should NOT add namespace prefix for Document
assert!(
!metadata_section.contains("i1.Document"),
"setClassMetadata should NOT namespace-prefix global types. Metadata section:\n{}",
metadata_section
);
}
7 changes: 6 additions & 1 deletion napi/angular-compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1928,7 +1928,12 @@ pub fn compile_class_metadata_sync(
let decorators_expr = core_build_decorator_metadata_array(&allocator, &[decorator_ref]);

// Build constructor parameters metadata
let ctor_params_expr = core_build_ctor_params_metadata(&allocator, class);
// This standalone API doesn't have full transform pipeline context (constructor deps
// and namespace registry), so imported types won't get namespace prefixes.
// The full transform_angular_file pipeline handles namespace prefixes correctly.
let mut namespace_registry = oxc_angular_compiler::NamespaceRegistry::new(&allocator);
let ctor_params_expr =
core_build_ctor_params_metadata(&allocator, class, None, &mut namespace_registry);

// Build property decorators metadata
let prop_decorators_expr = core_build_prop_decorators_metadata(&allocator, class);
Expand Down
Loading