Skip to content

Commit 5eb1a4f

Browse files
ashley-hunterclaude
andcommitted
fix: emit __decorate() for non-Angular decorators on stripped fields
When an uninitialized field has a non-Angular decorator (@select, @dispatch, etc.), the field declaration is stripped but the decorator must survive as a __decorate() call on the prototype. This matches tsc's output with useDefineForClassFields: false — the field is gone from the class body but the decorator is applied via __decorate(). Without this, stripping the field also removes the decorator, so the prototype getter is never set up and the property is undefined at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 83bca0b commit 5eb1a4f

File tree

2 files changed

+183
-3
lines changed

2 files changed

+183
-3
lines changed

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2326,7 +2326,18 @@ pub fn transform_angular_file(
23262326
// This must process ALL classes, not just Angular-decorated ones, because
23272327
// legacy decorators (@Select, @Dispatch) set up prototype getters that get
23282328
// shadowed by _defineProperty(this, "field", void 0) when fields aren't stripped.
2329+
//
2330+
// For fields with non-Angular decorators, we also emit __decorate() calls on
2331+
// the prototype so the decorator can set up its getter/value (matching tsc).
2332+
struct StrippedFieldDecorate {
2333+
class_name: String,
2334+
class_body_end: u32,
2335+
member_name: String,
2336+
decorator_texts: std::vec::Vec<String>,
2337+
}
23292338
let mut uninitialized_field_spans: Vec<Span> = Vec::new();
2339+
let mut stripped_field_decorates: std::vec::Vec<StrippedFieldDecorate> =
2340+
std::vec::Vec::new();
23302341
if options.strip_uninitialized_fields {
23312342
for stmt in &parser_ret.program.body {
23322343
let class = match stmt {
@@ -2344,6 +2355,9 @@ pub fn transform_angular_file(
23442355
_ => None,
23452356
};
23462357
let Some(class) = class else { continue };
2358+
let class_name = class.id.as_ref().map(|id| id.name.to_string());
2359+
let class_body_end = class.body.span.end;
2360+
23472361
for element in &class.body.body {
23482362
if let ClassElement::PropertyDefinition(prop) = element {
23492363
// Skip static fields — they follow different rules
@@ -2366,6 +2380,54 @@ pub fn transform_angular_file(
23662380
&& dec_span.end <= field_span.end)
23672381
});
23682382
uninitialized_field_spans.push(field_span);
2383+
2384+
// If the field has non-Angular decorators, collect them for
2385+
// __decorate() emission after the class.
2386+
if !prop.decorators.is_empty() {
2387+
let member_name = match &prop.key {
2388+
PropertyKey::StaticIdentifier(id) => id.name.to_string(),
2389+
_ => continue,
2390+
};
2391+
let mut non_angular_texts: std::vec::Vec<String> =
2392+
std::vec::Vec::new();
2393+
for decorator in &prop.decorators {
2394+
// Extract decorator name to check if it's Angular
2395+
let dec_name = match &decorator.expression {
2396+
Expression::CallExpression(call) => match &call.callee {
2397+
Expression::Identifier(id) => {
2398+
Some(id.name.as_str())
2399+
}
2400+
Expression::StaticMemberExpression(m) => {
2401+
Some(m.property.name.as_str())
2402+
}
2403+
_ => None,
2404+
},
2405+
Expression::Identifier(id) => Some(id.name.as_str()),
2406+
_ => None,
2407+
};
2408+
// Only emit __decorate for non-Angular decorators
2409+
if let Some(name) = dec_name {
2410+
if !ANGULAR_DECORATOR_NAMES.contains(&name) {
2411+
let expr_span = decorator.expression.span();
2412+
non_angular_texts.push(
2413+
source[expr_span.start as usize
2414+
..expr_span.end as usize]
2415+
.to_string(),
2416+
);
2417+
}
2418+
}
2419+
}
2420+
if !non_angular_texts.is_empty() {
2421+
if let Some(ref cn) = class_name {
2422+
stripped_field_decorates.push(StrippedFieldDecorate {
2423+
class_name: cn.clone(),
2424+
class_body_end,
2425+
member_name,
2426+
decorator_texts: non_angular_texts,
2427+
});
2428+
}
2429+
}
2430+
}
23692431
}
23702432
}
23712433
}
@@ -2451,6 +2513,27 @@ pub fn transform_angular_file(
24512513
edits.push(Edit::delete(span.start, end as u32));
24522514
}
24532515

2516+
// Emit __decorate() calls for non-Angular decorators on stripped fields.
2517+
// These go after the class body, matching tsc's output pattern.
2518+
if !stripped_field_decorates.is_empty() {
2519+
// Add tslib import if not already present
2520+
if !source.contains("__decorate") {
2521+
edits.push(
2522+
Edit::insert(0, "import { __decorate } from \"tslib\";\n".to_string())
2523+
.with_priority(10),
2524+
);
2525+
}
2526+
for entry in &stripped_field_decorates {
2527+
let decorate_call = format!(
2528+
"\n__decorate([{}], {}.prototype, \"{}\", void 0);",
2529+
entry.decorator_texts.join(", "),
2530+
entry.class_name,
2531+
entry.member_name,
2532+
);
2533+
edits.push(Edit::insert(entry.class_body_end, decorate_call));
2534+
}
2535+
}
2536+
24542537
if let Some(edit) = ns_edit {
24552538
edits.push(edit);
24562539
}

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9325,10 +9325,21 @@ export class TestComponent {
93259325
let result =
93269326
transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None);
93279327

9328-
// The uninitialized @Select field should be fully stripped (including decorator)
9328+
// The uninitialized @Select field should be stripped from the class body
93299329
assert!(
9330-
!result.code.contains("filters$"),
9331-
"Uninitialized @Select field should be stripped. Got:\n{}",
9330+
!result.code.contains(" filters$"),
9331+
"Uninitialized @Select field declaration should be stripped from class body. Got:\n{}",
9332+
result.code
9333+
);
9334+
// But a __decorate call should be emitted for the @Select decorator
9335+
assert!(
9336+
result.code.contains("__decorate([Select(SecurityOverviewState.filters)]"),
9337+
"__decorate call should be emitted for @Select decorator. Got:\n{}",
9338+
result.code
9339+
);
9340+
assert!(
9341+
result.code.contains("TestComponent.prototype, \"filters$\", void 0"),
9342+
"__decorate should target prototype. Got:\n{}",
93329343
result.code
93339344
);
93349345
// Initialized @Dispatch field should be preserved
@@ -9436,3 +9447,89 @@ export class TestComponent {
94369447
result.code
94379448
);
94389449
}
9450+
9451+
#[test]
9452+
fn test_strip_decorated_field_emits_decorate_call() {
9453+
// When an uninitialized field has a non-Angular decorator (@Select, @Dispatch, etc.),
9454+
// the field declaration must be stripped but a __decorate() call must be emitted
9455+
// on the prototype so the decorator can set up its getter/value.
9456+
let allocator = Allocator::default();
9457+
let source = r#"
9458+
import { Component } from '@angular/core';
9459+
9460+
@Component({ selector: 'test', template: '' })
9461+
export class TestComponent {
9462+
uninitialized: string;
9463+
@Select('someSelector') selectedValue$: Observable<any>;
9464+
@Dispatch() doAction = () => ({ type: 'ACTION' });
9465+
initialized = 'hello';
9466+
#privateField: string;
9467+
9468+
method() {
9469+
console.log(this.selectedValue$);
9470+
}
9471+
}
9472+
"#;
9473+
let options = ComponentTransformOptions {
9474+
strip_uninitialized_fields: true,
9475+
..Default::default()
9476+
};
9477+
let result =
9478+
transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None);
9479+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
9480+
9481+
// The @Select field should be stripped from class body
9482+
// (check it's not declared as a field — but references in methods are fine)
9483+
assert!(
9484+
!result.code.contains(" selectedValue$"),
9485+
"Uninitialized @Select field should be stripped from class body. Got:\n{}",
9486+
result.code
9487+
);
9488+
9489+
// A __decorate call should be emitted for the @Select decorator
9490+
assert!(
9491+
result.code.contains("__decorate([Select('someSelector')]"),
9492+
"__decorate call should be emitted for @Select decorator. Got:\n{}",
9493+
result.code
9494+
);
9495+
assert!(
9496+
result.code.contains("TestComponent.prototype, \"selectedValue$\", void 0"),
9497+
"__decorate should target prototype with void 0 descriptor. Got:\n{}",
9498+
result.code
9499+
);
9500+
9501+
// tslib import should be present
9502+
assert!(
9503+
result.code.contains("import { __decorate } from \"tslib\""),
9504+
"Should import __decorate from tslib. Got:\n{}",
9505+
result.code
9506+
);
9507+
9508+
// Initialized @Dispatch field should be preserved in class body
9509+
assert!(
9510+
result.code.contains("doAction"),
9511+
"Initialized @Dispatch field should be preserved. Got:\n{}",
9512+
result.code
9513+
);
9514+
9515+
// Plain uninitialized field should be stripped entirely (no __decorate)
9516+
assert!(
9517+
!result.code.contains("uninitialized"),
9518+
"Plain uninitialized field should be stripped. Got:\n{}",
9519+
result.code
9520+
);
9521+
9522+
// Private field should be preserved
9523+
assert!(
9524+
result.code.contains(" #privateField"),
9525+
"Private field should be preserved. Got:\n{}",
9526+
result.code
9527+
);
9528+
9529+
// Initialized plain field should be preserved
9530+
assert!(
9531+
result.code.contains("initialized = 'hello'"),
9532+
"Initialized field should be preserved. Got:\n{}",
9533+
result.code
9534+
);
9535+
}

0 commit comments

Comments
 (0)