Skip to content

Commit efe376a

Browse files
Brooooooklynclaude
andcommitted
fix: setClassMetadata ctor params use namespace-prefixed types for imported deps
Constructor parameter types in ɵsetClassMetadata were using bare names (e.g., SomeService) instead of namespace-prefixed references (e.g., i1.SomeService) for imported types. TypeScript erases type annotations at runtime, so imported types need namespace imports to remain available. The fix passes constructor dependency metadata and the namespace registry into build_ctor_params_metadata, and adds build_param_type_expression which uses namespace prefixes when the type annotation name matches the dep token name and the dep has a source module. When they differ (e.g., @Inject(DOCUMENT) doc: Document), falls back to bare name. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8100484 commit efe376a

File tree

5 files changed

+274
-10
lines changed

5 files changed

+274
-10
lines changed

crates/oxc_angular_compiler/src/class_metadata/builders.rs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use oxc_ast::ast::{
1010
};
1111
use oxc_span::Atom;
1212

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

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

129-
for param in constructor {
137+
for (i, param) in constructor.iter().enumerate() {
130138
let mut map_entries = AllocVec::new_in(allocator);
131139

132-
// Extract type from TypeScript type annotation
133-
let type_expr = extract_param_type_expression(allocator, param).unwrap_or_else(|| {
140+
// Extract type from TypeScript type annotation, using namespace-prefixed
141+
// references for imported types when constructor dependency info is available.
142+
let type_expr = build_param_type_expression(
143+
allocator,
144+
param,
145+
constructor_deps.and_then(|deps| deps.get(i)),
146+
namespace_registry,
147+
)
148+
.unwrap_or_else(|| {
134149
OutputExpression::Literal(Box::new_in(
135150
LiteralExpr { value: LiteralValue::Undefined, source_span: None },
136151
allocator,
@@ -257,7 +272,80 @@ pub fn build_prop_decorators_metadata<'a>(
257272
// Internal helper functions
258273
// ============================================================================
259274

260-
/// Extract the type expression from a constructor parameter.
275+
/// Build the type expression for a constructor parameter, using namespace-prefixed
276+
/// references for imported types.
277+
///
278+
/// TypeScript type annotations are erased at runtime, so imported types need namespace
279+
/// imports (e.g., `i1.SomeService`) to be available as runtime values.
280+
///
281+
/// The `dep.token_source_module` tracks where the injection token comes from. We only
282+
/// use it for namespace prefix when the type annotation name matches the dep token name,
283+
/// confirming that the dep's source module applies to the type. When they differ
284+
/// (e.g., `@Inject(DOCUMENT) doc: Document`), we fall back to bare name since the type
285+
/// may be a global or from a different module.
286+
fn build_param_type_expression<'a>(
287+
allocator: &'a Allocator,
288+
param: &FormalParameter<'a>,
289+
dep: Option<&R3DependencyMetadata<'a>>,
290+
namespace_registry: &mut NamespaceRegistry<'a>,
291+
) -> Option<OutputExpression<'a>> {
292+
// Extract the type name from the type annotation
293+
let type_name = extract_param_type_name(param);
294+
295+
// Use namespace prefix when the type annotation matches the dep token name
296+
// and the dep has a source module (imported type).
297+
if let Some(dep) = dep {
298+
if let Some(ref source_module) = dep.token_source_module {
299+
if let Some(ref token) = dep.token {
300+
let type_matches_token =
301+
type_name.as_ref().is_some_and(|tn| tn.as_str() == token.as_str());
302+
303+
if type_matches_token {
304+
let name = type_name.unwrap_or_else(|| token.clone());
305+
let namespace = namespace_registry.get_or_assign(source_module);
306+
return Some(OutputExpression::ReadProp(Box::new_in(
307+
ReadPropExpr {
308+
receiver: Box::new_in(
309+
OutputExpression::ReadVar(Box::new_in(
310+
ReadVarExpr { name: namespace, source_span: None },
311+
allocator,
312+
)),
313+
allocator,
314+
),
315+
name,
316+
optional: false,
317+
source_span: None,
318+
},
319+
allocator,
320+
)));
321+
}
322+
}
323+
}
324+
}
325+
326+
// Fall back to extracting the bare type name from the type annotation
327+
extract_param_type_expression(allocator, param)
328+
}
329+
330+
/// Extract the type name (as an Atom) from a constructor parameter's type annotation.
331+
///
332+
/// Returns the simple type name from the annotation, if present.
333+
/// Used to get the type name for namespace-prefixed references in metadata.
334+
fn extract_param_type_name<'a>(param: &FormalParameter<'a>) -> Option<Atom<'a>> {
335+
let type_annotation = param.type_annotation.as_ref()?;
336+
match &type_annotation.type_annotation {
337+
TSType::TSTypeReference(type_ref) => match &type_ref.type_name {
338+
TSTypeName::IdentifierReference(id) => Some(id.name),
339+
TSTypeName::QualifiedName(qualified) => Some(qualified.right.name),
340+
TSTypeName::ThisExpression(_) => None,
341+
},
342+
_ => None,
343+
}
344+
}
345+
346+
/// Extract the type expression from a constructor parameter's type annotation.
347+
///
348+
/// This is the fallback path for local types that don't need namespace prefixes.
261349
fn extract_param_type_expression<'a>(
262350
allocator: &'a Allocator,
263351
param: &FormalParameter<'a>,

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,14 +779,22 @@ pub fn transform_angular_file(
779779
);
780780

781781
// Build metadata from the class AST
782+
// Pass constructor deps and namespace registry so that
783+
// imported types get namespace-prefixed references
784+
// (e.g., i1.SomeService instead of bare SomeService)
785+
let ctor_deps_slice =
786+
metadata.constructor_deps.as_ref().map(|v| v.as_slice());
782787
let class_metadata = R3ClassMetadata {
783788
r#type: type_expr,
784789
decorators: build_decorator_metadata_array(
785790
allocator,
786791
&[decorator],
787792
),
788793
ctor_parameters: build_ctor_params_metadata(
789-
allocator, class,
794+
allocator,
795+
class,
796+
ctor_deps_slice,
797+
&mut file_namespace_registry,
790798
),
791799
prop_decorators: build_prop_decorators_metadata(
792800
allocator, class,

crates/oxc_angular_compiler/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ pub use transform::{HtmlToR3Transform, html_to_r3::html_ast_to_r3_ast};
5757
pub use component::{
5858
AngularVersion, ChangeDetectionStrategy, CompiledComponent, ComponentMetadata,
5959
HmrTemplateCompileOutput, HostMetadata, HostMetadataInput, ImportInfo, ImportMap,
60-
ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult, ViewEncapsulation,
61-
build_import_map, compile_component_template, compile_for_hmr, compile_template_for_hmr,
62-
compile_template_to_js, compile_template_to_js_with_options, extract_component_metadata,
63-
transform_angular_file,
60+
NamespaceRegistry, ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult,
61+
ViewEncapsulation, build_import_map, compile_component_template, compile_for_hmr,
62+
compile_template_for_hmr, compile_template_to_js, compile_template_to_js_with_options,
63+
extract_component_metadata, transform_angular_file,
6464
};
6565

6666
// Re-export cross-file elision types when feature is enabled

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3977,3 +3977,166 @@ fn test_for_index_xref_with_i18n_attribute_binding() {
39773977
// matching Angular TS which stores direct i18n.Message object references on BindingOp.
39783978
insta::assert_snapshot!("for_index_xref_with_i18n_attribute_binding", js);
39793979
}
3980+
3981+
/// Tests that setClassMetadata uses namespace-prefixed type references for imported
3982+
/// constructor parameter types.
3983+
///
3984+
/// Angular's TypeScript compiler distinguishes between local and imported types in
3985+
/// the ɵsetClassMetadata constructor parameter metadata:
3986+
/// - Local types use bare names: `{ type: LocalService }`
3987+
/// - Imported types use namespace-prefixed names: `{ type: i1.ImportedService }`
3988+
///
3989+
/// This is because TypeScript type annotations are erased at runtime, so imported
3990+
/// types need namespace imports (i0, i1, i2...) to be available as runtime values.
3991+
/// The factory function (ɵfac) already handles this correctly via R3DependencyMetadata
3992+
/// and create_token_expression, but setClassMetadata was using bare names for all types.
3993+
#[test]
3994+
fn test_set_class_metadata_uses_namespace_for_imported_ctor_params() {
3995+
let allocator = Allocator::default();
3996+
let source = r#"
3997+
import { Component } from '@angular/core';
3998+
import { SomeService } from './some.service';
3999+
4000+
@Component({
4001+
selector: 'test-comp',
4002+
template: '<div>hello</div>',
4003+
standalone: true,
4004+
})
4005+
export class TestComponent {
4006+
constructor(private svc: SomeService) {}
4007+
}
4008+
"#;
4009+
4010+
let options = ComponentTransformOptions {
4011+
emit_class_metadata: true,
4012+
..ComponentTransformOptions::default()
4013+
};
4014+
4015+
let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);
4016+
4017+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
4018+
4019+
// Extract the setClassMetadata section specifically (not the factory function)
4020+
let metadata_section = result
4021+
.code
4022+
.split("ɵsetClassMetadata")
4023+
.nth(1)
4024+
.expect("setClassMetadata should be present in output");
4025+
4026+
// The ctor_parameters callback should use namespace-prefixed type for
4027+
// the imported SomeService: `{type:i1.SomeService}` not `{type:SomeService}`
4028+
assert!(
4029+
metadata_section.contains("i1.SomeService"),
4030+
"setClassMetadata ctor_parameters should use namespace-prefixed type (i1.SomeService) for imported constructor parameter. Metadata section:\n{}",
4031+
metadata_section
4032+
);
4033+
assert!(
4034+
!metadata_section.contains("type:SomeService}"),
4035+
"setClassMetadata should NOT use bare type name for imported types. Metadata section:\n{}",
4036+
metadata_section
4037+
);
4038+
}
4039+
4040+
/// Tests that setClassMetadata uses namespace-prefixed type even when @Inject is present.
4041+
///
4042+
/// When a constructor parameter has both a type annotation and @Inject decorator pointing
4043+
/// to the same imported class, the metadata `type` field should still use namespace prefix.
4044+
/// The factory correctly uses bare names for @Inject tokens with named imports, but the
4045+
/// metadata type always represents the TypeScript type annotation which is erased at runtime.
4046+
///
4047+
/// Example:
4048+
/// - Factory: `ɵɵdirectiveInject(TagPickerComponent, 12)` (bare - ok, @Inject value import)
4049+
/// - Metadata: `{ type: i1.TagPickerComponent, decorators: [{type: Inject, ...}] }` (namespace)
4050+
#[test]
4051+
fn test_set_class_metadata_namespace_with_inject_decorator() {
4052+
let allocator = Allocator::default();
4053+
let source = r#"
4054+
import { Component, Inject, Optional, SkipSelf } from '@angular/core';
4055+
import { SomeService } from './some.service';
4056+
4057+
@Component({
4058+
selector: 'test-comp',
4059+
template: '<div>hello</div>',
4060+
standalone: true,
4061+
})
4062+
export class TestComponent {
4063+
constructor(
4064+
@Optional() @SkipSelf() @Inject(SomeService) private svc: SomeService
4065+
) {}
4066+
}
4067+
"#;
4068+
4069+
let options = ComponentTransformOptions {
4070+
emit_class_metadata: true,
4071+
..ComponentTransformOptions::default()
4072+
};
4073+
4074+
let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);
4075+
4076+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
4077+
4078+
// Extract the setClassMetadata section
4079+
let metadata_section = result
4080+
.code
4081+
.split("ɵsetClassMetadata")
4082+
.nth(1)
4083+
.expect("setClassMetadata should be present in output");
4084+
4085+
// Even with @Inject(SomeService), the type field should use namespace prefix
4086+
// because the type annotation is erased by TypeScript
4087+
assert!(
4088+
metadata_section.contains("i1.SomeService"),
4089+
"setClassMetadata should use namespace-prefixed type even with @Inject. Metadata section:\n{}",
4090+
metadata_section
4091+
);
4092+
}
4093+
4094+
/// Tests that when @Inject token differs from the type annotation (e.g., @Inject(DOCUMENT)
4095+
/// on a parameter typed as Document), the metadata type uses bare name since the type
4096+
/// annotation may reference a global or different module than the injection token.
4097+
#[test]
4098+
fn test_set_class_metadata_inject_differs_from_type() {
4099+
let allocator = Allocator::default();
4100+
let source = r#"
4101+
import { Component, Inject } from '@angular/core';
4102+
import { DOCUMENT } from '@angular/common';
4103+
4104+
@Component({
4105+
selector: 'test-comp',
4106+
template: '<div>hello</div>',
4107+
standalone: true,
4108+
})
4109+
export class TestComponent {
4110+
constructor(@Inject(DOCUMENT) private doc: Document) {}
4111+
}
4112+
"#;
4113+
4114+
let options = ComponentTransformOptions {
4115+
emit_class_metadata: true,
4116+
..ComponentTransformOptions::default()
4117+
};
4118+
4119+
let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);
4120+
4121+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
4122+
4123+
let metadata_section = result
4124+
.code
4125+
.split("ɵsetClassMetadata")
4126+
.nth(1)
4127+
.expect("setClassMetadata should be present in output");
4128+
4129+
// The type should be bare "Document" (global type), not namespace-prefixed
4130+
// even though the @Inject token (DOCUMENT) is from @angular/common
4131+
assert!(
4132+
metadata_section.contains("type:Document"),
4133+
"setClassMetadata should use bare type for globals when @Inject token differs. Metadata section:\n{}",
4134+
metadata_section
4135+
);
4136+
// Should NOT add namespace prefix for Document
4137+
assert!(
4138+
!metadata_section.contains("i1.Document"),
4139+
"setClassMetadata should NOT namespace-prefix global types. Metadata section:\n{}",
4140+
metadata_section
4141+
);
4142+
}

napi/angular-compiler/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1928,7 +1928,12 @@ pub fn compile_class_metadata_sync(
19281928
let decorators_expr = core_build_decorator_metadata_array(&allocator, &[decorator_ref]);
19291929

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

19331938
// Build property decorators metadata
19341939
let prop_decorators_expr = core_build_prop_decorators_metadata(&allocator, class);

0 commit comments

Comments
 (0)