From 14d4054fe8f2ef8da121500b3707a9f7de9034d4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 18:36:42 +0800 Subject: [PATCH] fix(compiler): match ngtsc propDecorators member shape in setClassMetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OXC's AOT `setClassMetadata` propDecorators diverged from ngtsc's `extractClassMetadata` (metadata.ts) in two ways: 1. Static members were included. Upstream filters `!member.isStatic` (and `accessLevel !== EcmaScriptPrivate`); OXC did not check `r#static`, so a decorated static member — or a `static x = input(0)` picked up by the synthesized initializer-API path — leaked into `propDecorators` while ngtsc omits it. 2. String-literal member keys were not marked quoted. Upstream sets `quoted: shouldQuoteName` when the member's name node is a string literal (`ts.isStringLiteralLike`), so `'x'` emits `"x":` while OXC emitted `x:`. (Illegal identifiers like 'x-y' were already quoted by the emitter's identifier check, so this is a textual divergence, not invalid output.) `build_prop_decorators_metadata_in` now skips `r#static` elements and threads a `quoted` flag from the member key (string literal or no-substitution template literal) into both `LiteralMapEntry`s, matching upstream's quote behavior exactly. The upstream duplicate-decorated-members diagnostic is not ported: it is unreachable in practice (the only same-name pair is a getter/setter, which cannot both carry decorators) and `builders.rs` has no diagnostic channel. Fixes #539 --- .../src/class_metadata/builders.rs | 40 +++++++++-- .../tests/integration_test.rs | 68 +++++++++++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index efa1bad60..1656baabc 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -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; }; @@ -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; } @@ -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)); } } @@ -988,3 +996,21 @@ fn get_property_key_name<'a>(key: &PropertyKey<'a>) -> Option> { _ => 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, + } +} diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index ada80cfbf..88e797959 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -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: '
hello
', + 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 // ============================================================================