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
40 changes: 33 additions & 7 deletions crates/oxc_angular_compiler/src/class_metadata/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,20 +509,28 @@ pub fn build_prop_decorators_metadata_in<'a>(
let mut prop_entries = AllocVec::new_in(&allocator);

for element in &class.body.body {
let (decorators, property_name, value) = match element {
let (decorators, key, is_static, value) = match element {
ClassElement::PropertyDefinition(prop) => {
(&prop.decorators, get_property_key_name(&prop.key), prop.value.as_ref())
(&prop.decorators, &prop.key, prop.r#static, prop.value.as_ref())
}
ClassElement::MethodDefinition(method) => {
(&method.decorators, get_property_key_name(&method.key), None)
(&method.decorators, &method.key, method.r#static, None)
}
ClassElement::AccessorProperty(prop) => {
(&prop.decorators, get_property_key_name(&prop.key), prop.value.as_ref())
(&prop.decorators, &prop.key, prop.r#static, prop.value.as_ref())
}
_ => continue,
};

let Some(prop_name) = property_name else {
// ngtsc excludes static and ECMAScript-private members from
// `propDecorators` (`!member.isStatic && accessLevel !==
// EcmaScriptPrivate`); a private name yields `None` from
// `member_key_name` either way.
if is_static {
continue;
}

let Some((prop_name, quoted)) = member_key_name(key) else {
continue;
};

Expand All @@ -546,7 +554,7 @@ pub fn build_prop_decorators_metadata_in<'a>(
None,
None,
);
prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false));
prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, quoted));
continue;
}

Expand All @@ -566,7 +574,7 @@ pub fn build_prop_decorators_metadata_in<'a>(
consts,
)
{
prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false));
prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, quoted));
}
}

Expand Down Expand Up @@ -988,3 +996,21 @@ fn get_property_key_name<'a>(key: &PropertyKey<'a>) -> Option<Ident<'a>> {
_ => None,
}
}

/// A class member's `propDecorators` key and whether it's quoted.
///
/// Like ngtsc's `shouldQuoteName` (`ts.isStringLiteralLike(nameNode)`), a
/// string-literal key (`'x-y'`, `'x'`) is always quoted in the emitted object
/// literal; a bare identifier is not. A no-substitution template literal
/// counts too; private (`#x`), computed and numeric keys have no usable name
/// and return `None`.
fn member_key_name<'a>(key: &PropertyKey<'a>) -> Option<(Ident<'a>, bool)> {
match key {
PropertyKey::StaticIdentifier(id) => Some((id.name.into(), false)),
PropertyKey::StringLiteral(lit) => Some((lit.value.into(), true)),
PropertyKey::TemplateLiteral(tpl) if tpl.expressions.is_empty() => {
tpl.quasis.first().and_then(|q| q.value.cooked.clone().map(|v| (v.into(), true)))
}
_ => None,
}
}
68 changes: 68 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6137,6 +6137,74 @@ export class TestComponent {
);
}

/// `setClassMetadata`'s `propDecorators` mirrors ngtsc's `extractClassMetadata`
/// (metadata.ts): static and ECMAScript-private members are excluded, and
/// string-literal member keys are emitted quoted (`shouldQuoteName`).
#[test]
fn test_set_class_metadata_prop_decorators_member_shape() {
let allocator = Allocator::default();
let source = r"
import { Component, Input } from '@angular/core';
import { input } from '@angular/core';

@Component({
selector: 'test-comp',
template: '<div>hello</div>',
standalone: true,
})
export class TestComponent {
@Input() instanceProp: any;
@Input() static staticProp: any;
@Input() #priv: any;
@Input() 'str-key': any;
static statSignal = input(0);
}
";

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

let result =
transform_angular_file(&allocator, "test.component.ts", source, Some(&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");
let compact: String = metadata_section.chars().filter(|c| !c.is_whitespace()).collect();

// Instance member with an Angular decorator is listed.
assert!(
compact.contains("instanceProp:[{type:Input}]"),
"instance member should be in propDecorators. Metadata:\n{metadata_section}"
);
// String-literal key is quoted, like ngtsc's shouldQuoteName.
assert!(
compact.contains(r#""str-key":[{type:Input}]"#),
"string-literal member key should be quoted in propDecorators. Metadata:\n{metadata_section}"
);
// Static members — decorated or signal-initialized — are excluded, like
// ngtsc's `!member.isStatic` filter.
assert!(
!compact.contains("staticProp:"),
"static members should not appear in propDecorators. Metadata:\n{metadata_section}"
);
assert!(
!compact.contains("statSignal:"),
"static signal member should not get a synthesized entry. Metadata:\n{metadata_section}"
);
// ECMAScript-private members are excluded.
assert!(
!compact.contains("priv:"),
"private members should not appear in propDecorators. Metadata:\n{metadata_section}"
);
}

// ============================================================================
// Namespace Attribute Const Collection Tests
// ============================================================================
Expand Down
Loading