Skip to content

Commit 61b518f

Browse files
Brooooooklynclaude
andcommitted
fix: JIT transform correctly downlevels member decorators and union types
Fixes two bugs identified in code review: 1. (High) Member decorators (@input, @output, @HostBinding, etc.) were removed from class bodies without being emitted as `static propDecorators`. Angular's JIT runtime reads `propDecorators` to discover inputs/outputs/ queries at runtime — without it, data binding silently breaks. Now emits: static propDecorators = { myInput: [{ type: Input }], myOutput: [{ type: Output }], }; 2. (Medium) `extract_type_name_from_annotation` only skipped TSNullKeyword in union types. For `undefined | T` or `null | undefined | T`, the function encountered TSUndefinedKeyword first, immediately recursed and returned None, never reaching the actual type. Fixed to try each union member and return the first resolvable name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2d4c849 commit 61b518f

File tree

5 files changed

+308
-6
lines changed

5 files changed

+308
-6
lines changed

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,8 @@ struct JitClassInfo {
675675
is_default_export: bool,
676676
/// Constructor parameter info for ctorParameters.
677677
ctor_params: std::vec::Vec<JitCtorParam>,
678+
/// Member decorator info for propDecorators.
679+
member_decorators: std::vec::Vec<JitMemberDecorator>,
678680
/// The modified decorator expression text for __decorate call.
679681
decorator_text: String,
680682
}
@@ -695,6 +697,14 @@ struct JitParamDecorator {
695697
args: Option<String>,
696698
}
697699

700+
/// A member (property/method) with its Angular decorators for propDecorators.
701+
struct JitMemberDecorator {
702+
/// The property/member name.
703+
member_name: String,
704+
/// The Angular decorators on this member.
705+
decorators: std::vec::Vec<JitParamDecorator>,
706+
}
707+
698708
/// Find any Angular decorator on a class and return its kind and the decorator reference.
699709
fn find_angular_decorator<'a>(
700710
class: &'a oxc_ast::ast::Class<'a>,
@@ -788,6 +798,122 @@ fn extract_jit_ctor_params(
788798
params
789799
}
790800

801+
/// Extract Angular member decorators for JIT propDecorators generation.
802+
///
803+
/// Collects all Angular-relevant decorators from class properties/methods
804+
/// (excluding constructor) so they can be emitted as a `static propDecorators` property.
805+
fn extract_jit_member_decorators(
806+
source: &str,
807+
class: &oxc_ast::ast::Class<'_>,
808+
) -> std::vec::Vec<JitMemberDecorator> {
809+
use oxc_ast::ast::{ClassElement, MethodDefinitionKind, PropertyKey};
810+
811+
const ANGULAR_MEMBER_DECORATORS: &[&str] = &[
812+
"Input",
813+
"Output",
814+
"HostBinding",
815+
"HostListener",
816+
"ViewChild",
817+
"ViewChildren",
818+
"ContentChild",
819+
"ContentChildren",
820+
];
821+
822+
let mut result: std::vec::Vec<JitMemberDecorator> = std::vec::Vec::new();
823+
824+
for element in &class.body.body {
825+
let (member_name, decorators) = match element {
826+
ClassElement::PropertyDefinition(prop) => {
827+
let name = match &prop.key {
828+
PropertyKey::StaticIdentifier(id) => id.name.to_string(),
829+
PropertyKey::StringLiteral(s) => s.value.to_string(),
830+
_ => continue,
831+
};
832+
(name, &prop.decorators)
833+
}
834+
ClassElement::MethodDefinition(method) => {
835+
if method.kind == MethodDefinitionKind::Constructor {
836+
continue;
837+
}
838+
let name = match &method.key {
839+
PropertyKey::StaticIdentifier(id) => id.name.to_string(),
840+
PropertyKey::StringLiteral(s) => s.value.to_string(),
841+
_ => continue,
842+
};
843+
(name, &method.decorators)
844+
}
845+
ClassElement::AccessorProperty(accessor) => {
846+
let name = match &accessor.key {
847+
PropertyKey::StaticIdentifier(id) => id.name.to_string(),
848+
PropertyKey::StringLiteral(s) => s.value.to_string(),
849+
_ => continue,
850+
};
851+
(name, &accessor.decorators)
852+
}
853+
_ => continue,
854+
};
855+
856+
let mut angular_decs: std::vec::Vec<JitParamDecorator> = std::vec::Vec::new();
857+
858+
for decorator in decorators {
859+
let (dec_name, call_args) = match &decorator.expression {
860+
Expression::CallExpression(call) => {
861+
let name = match &call.callee {
862+
Expression::Identifier(id) => id.name.to_string(),
863+
Expression::StaticMemberExpression(m) => m.property.name.to_string(),
864+
_ => continue,
865+
};
866+
let args = if call.arguments.is_empty() {
867+
None
868+
} else {
869+
let start = call.arguments.first().unwrap().span().start;
870+
let end = call.arguments.last().unwrap().span().end;
871+
Some(source[start as usize..end as usize].to_string())
872+
};
873+
(name, args)
874+
}
875+
Expression::Identifier(id) => (id.name.to_string(), None),
876+
_ => continue,
877+
};
878+
879+
if ANGULAR_MEMBER_DECORATORS.contains(&dec_name.as_str()) {
880+
angular_decs.push(JitParamDecorator { name: dec_name, args: call_args });
881+
}
882+
}
883+
884+
if !angular_decs.is_empty() {
885+
result.push(JitMemberDecorator { member_name, decorators: angular_decs });
886+
}
887+
}
888+
889+
result
890+
}
891+
892+
/// Build the propDecorators static property text for JIT member decorator metadata.
893+
fn build_prop_decorators_text(members: &[JitMemberDecorator]) -> Option<String> {
894+
if members.is_empty() {
895+
return None;
896+
}
897+
898+
let mut entries: std::vec::Vec<String> = std::vec::Vec::new();
899+
for member in members {
900+
let dec_strs: std::vec::Vec<String> = member
901+
.decorators
902+
.iter()
903+
.map(|d| {
904+
if let Some(ref args) = d.args {
905+
format!("{{ type: {}, args: [{}] }}", d.name, args)
906+
} else {
907+
format!("{{ type: {} }}", d.name)
908+
}
909+
})
910+
.collect();
911+
entries.push(format!(" {}: [{}]", member.member_name, dec_strs.join(", ")));
912+
}
913+
914+
Some(format!("static propDecorators = {{\n{}\n}}", entries.join(",\n")))
915+
}
916+
791917
/// Extract a type name from a TypeScript type annotation for JIT ctorParameters.
792918
fn extract_type_name_from_annotation(type_annotation: &oxc_ast::ast::TSType<'_>) -> Option<String> {
793919
match type_annotation {
@@ -803,10 +929,13 @@ fn extract_type_name_from_annotation(type_annotation: &oxc_ast::ast::TSType<'_>)
803929
}
804930
}
805931
oxc_ast::ast::TSType::TSUnionType(union) => {
806-
// For union types like `T | null`, try to find the non-null type
932+
// For union types like `T | null`, `undefined | T`, `null | undefined | T`,
933+
// iterate all members and return the first that resolves to a name.
934+
// Using try-each-and-continue handles TSNullKeyword, TSUndefinedKeyword,
935+
// and any other non-reference type gracefully.
807936
for t in &union.types {
808-
if !matches!(t, oxc_ast::ast::TSType::TSNullKeyword(_)) {
809-
return extract_type_name_from_annotation(t);
937+
if let Some(name) = extract_type_name_from_annotation(t) {
938+
return Some(name);
810939
}
811940
}
812941
None
@@ -1062,6 +1191,9 @@ fn transform_angular_file_jit(
10621191
// Extract constructor parameters for ctorParameters
10631192
let ctor_params = extract_jit_ctor_params(source, class);
10641193

1194+
// Extract member decorators for propDecorators
1195+
let member_decorators = extract_jit_member_decorators(source, class);
1196+
10651197
jit_classes.push(JitClassInfo {
10661198
class_name,
10671199
decorator_span: decorator.span,
@@ -1071,6 +1203,7 @@ fn transform_angular_file_jit(
10711203
is_exported,
10721204
is_default_export,
10731205
ctor_params,
1206+
member_decorators,
10741207
decorator_text,
10751208
});
10761209

@@ -1195,9 +1328,19 @@ fn transform_angular_file_jit(
11951328
));
11961329
}
11971330

1198-
// 4d. Add ctorParameters inside class body (before closing `}`)
1199-
if let Some(ctor_text) = build_ctor_parameters_text(&jit_info.ctor_params) {
1200-
edits.push(Edit::insert(jit_info.class_body_end - 1, format!("\n{};\n", ctor_text)));
1331+
// 4d. Add ctorParameters and propDecorators inside class body (before closing `}`)
1332+
{
1333+
let mut class_statics = String::new();
1334+
if let Some(ctor_text) = build_ctor_parameters_text(&jit_info.ctor_params) {
1335+
class_statics.push_str(&format!("\n{};", ctor_text));
1336+
}
1337+
if let Some(prop_text) = build_prop_decorators_text(&jit_info.member_decorators) {
1338+
class_statics.push_str(&format!("\n{};", prop_text));
1339+
}
1340+
if !class_statics.is_empty() {
1341+
class_statics.push('\n');
1342+
edits.push(Edit::insert(jit_info.class_body_end - 1, class_statics));
1343+
}
12011344
}
12021345

12031346
// 4e. After class body, add __decorate call and export

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6245,3 +6245,108 @@ export class App {
62456245

62466246
insta::assert_snapshot!("jit_full_component", result.code);
62476247
}
6248+
6249+
#[test]
6250+
fn test_jit_prop_decorators_emitted() {
6251+
// Bug fix: member decorators (@Input, @Output, etc.) must be downleveled
6252+
// to static propDecorators so Angular's JIT runtime can discover inputs/outputs.
6253+
// Without this, @Input/@Output decorators are silently lost, breaking data binding.
6254+
let allocator = Allocator::default();
6255+
let source = r#"
6256+
import { Directive, Input, Output, HostBinding, EventEmitter } from '@angular/core';
6257+
6258+
@Directive({
6259+
selector: '[appHighlight]',
6260+
})
6261+
export class HighlightDirective {
6262+
@Input() color: string = 'yellow';
6263+
@Input('aliasName') title: string = '';
6264+
@Output() colorChange = new EventEmitter<string>();
6265+
@HostBinding('class.active') isActive = false;
6266+
}
6267+
"#;
6268+
6269+
let options = ComponentTransformOptions { jit: true, ..Default::default() };
6270+
let result =
6271+
transform_angular_file(&allocator, "highlight.directive.ts", source, &options, None);
6272+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
6273+
6274+
// propDecorators must be present — Angular's JIT runtime reads this
6275+
assert!(
6276+
result.code.contains("propDecorators"),
6277+
"JIT output must emit static propDecorators. Got:\n{}",
6278+
result.code
6279+
);
6280+
6281+
// Each decorated member should appear in propDecorators
6282+
assert!(result.code.contains("color:"), "propDecorators should list 'color'");
6283+
assert!(result.code.contains("title:"), "propDecorators should list 'title'");
6284+
assert!(result.code.contains("colorChange:"), "propDecorators should list 'colorChange'");
6285+
assert!(result.code.contains("isActive:"), "propDecorators should list 'isActive'");
6286+
6287+
// The decorator type references should be present
6288+
assert!(result.code.contains("type: Input"), "propDecorators should reference Input");
6289+
assert!(result.code.contains("type: Output"), "propDecorators should reference Output");
6290+
assert!(
6291+
result.code.contains("type: HostBinding"),
6292+
"propDecorators should reference HostBinding"
6293+
);
6294+
6295+
// The original decorators must be removed from the class body
6296+
assert!(
6297+
!result.code.contains("@Input()"),
6298+
"@Input decorator must be removed from class body"
6299+
);
6300+
assert!(
6301+
!result.code.contains("@Output()"),
6302+
"@Output decorator must be removed from class body"
6303+
);
6304+
6305+
insta::assert_snapshot!("jit_prop_decorators", result.code);
6306+
}
6307+
6308+
#[test]
6309+
fn test_jit_union_type_ctor_params() {
6310+
// Bug fix: union types like `undefined | SomeService` or `null | undefined | T`
6311+
// must correctly extract the type name for ctorParameters.
6312+
// Previously only TSNullKeyword was skipped, causing TSUndefinedKeyword to short-circuit.
6313+
let allocator = Allocator::default();
6314+
let source = r#"
6315+
import { Component } from '@angular/core';
6316+
import { ServiceA } from './a.service';
6317+
import { ServiceB } from './b.service';
6318+
6319+
@Component({ selector: 'test', template: '' })
6320+
export class TestComponent {
6321+
constructor(
6322+
svcA: undefined | ServiceA,
6323+
svcB: null | undefined | ServiceB,
6324+
) {}
6325+
}
6326+
"#;
6327+
6328+
let options = ComponentTransformOptions { jit: true, ..Default::default() };
6329+
let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);
6330+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
6331+
6332+
// Both types must be correctly extracted despite union with undefined/null
6333+
assert!(
6334+
result.code.contains("type: ServiceA"),
6335+
"ctorParameters should resolve 'undefined | ServiceA' to ServiceA. Got:\n{}",
6336+
result.code
6337+
);
6338+
assert!(
6339+
result.code.contains("type: ServiceB"),
6340+
"ctorParameters should resolve 'null | undefined | ServiceB' to ServiceB. Got:\n{}",
6341+
result.code
6342+
);
6343+
6344+
// Should NOT emit 'type: undefined' for either
6345+
assert!(
6346+
!result.code.contains("type: undefined"),
6347+
"ctorParameters must not emit 'type: undefined' for resolvable union types. Got:\n{}",
6348+
result.code
6349+
);
6350+
6351+
insta::assert_snapshot!("jit_union_type_ctor_params", result.code);
6352+
}

crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { __decorate } from "tslib";
77

88
let HighlightDirective = class HighlightDirective {
99
color: string = 'yellow';
10+
11+
static propDecorators = {
12+
color: [{ type: Input }]
13+
};
1014
};
1115
HighlightDirective = __decorate([
1216
Directive({
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: result.code
4+
---
5+
import { Directive, Input, Output, HostBinding, EventEmitter } from '@angular/core';
6+
import { __decorate } from "tslib";
7+
8+
let HighlightDirective = class HighlightDirective {
9+
color: string = 'yellow';
10+
title: string = '';
11+
colorChange = new EventEmitter<string>();
12+
isActive = false;
13+
14+
static propDecorators = {
15+
color: [{ type: Input }],
16+
title: [{ type: Input, args: ['aliasName'] }],
17+
colorChange: [{ type: Output }],
18+
isActive: [{ type: HostBinding, args: ['class.active'] }]
19+
};
20+
};
21+
HighlightDirective = __decorate([
22+
Directive({
23+
selector: '[appHighlight]',
24+
})
25+
], HighlightDirective);
26+
export { HighlightDirective };
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: result.code
4+
---
5+
import { Component } from '@angular/core';
6+
import { ServiceA } from './a.service';
7+
import { ServiceB } from './b.service';
8+
import { __decorate } from "tslib";
9+
10+
let TestComponent = class TestComponent {
11+
constructor(
12+
svcA: undefined | ServiceA,
13+
svcB: null | undefined | ServiceB,
14+
) {}
15+
16+
static ctorParameters = () => [
17+
{ type: ServiceA },
18+
{ type: ServiceB }
19+
];
20+
};
21+
TestComponent = __decorate([
22+
Component({ selector: 'test', template: '' })
23+
], TestComponent);
24+
export { TestComponent };

0 commit comments

Comments
 (0)