Skip to content

Commit 56a1f69

Browse files
Brooooooklynclaude
andcommitted
fix(jit): align union type resolution with Angular's typeReferenceToExpression
Angular's reference filters only `null` literal types from union types and requires exactly one non-null type to remain. Previously we iterated all union members and returned the first resolvable one, which is more permissive than Angular's behavior. With Angular-aligned semantics: - `T | null` → resolves to T (1 non-null type remains) - `undefined | T` → unresolvable (2 non-null types remain) - `null | undefined | T` → unresolvable (2 non-null types remain) Unresolvable types emit `{ type: undefined }` matching Angular's `paramType || ts.factory.createIdentifier('undefined')` fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 61b518f commit 56a1f69

File tree

3 files changed

+39
-28
lines changed

3 files changed

+39
-28
lines changed

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -929,16 +929,16 @@ fn extract_type_name_from_annotation(type_annotation: &oxc_ast::ast::TSType<'_>)
929929
}
930930
}
931931
oxc_ast::ast::TSType::TSUnionType(union) => {
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.
936-
for t in &union.types {
937-
if let Some(name) = extract_type_name_from_annotation(t) {
938-
return Some(name);
939-
}
940-
}
941-
None
932+
// Match Angular's typeReferenceToExpression behavior:
933+
// filter out only `null` literal types, and if exactly one type remains,
934+
// resolve that type. Otherwise, return None (unresolvable).
935+
// See: angular/packages/compiler-cli/src/ngtsc/transform/jit/src/downlevel_decorators_transform.ts
936+
let non_null: std::vec::Vec<_> = union
937+
.types
938+
.iter()
939+
.filter(|t| !matches!(t, oxc_ast::ast::TSType::TSNullKeyword(_)))
940+
.collect();
941+
if non_null.len() == 1 { extract_type_name_from_annotation(non_null[0]) } else { None }
942942
}
943943
_ => None,
944944
}

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6293,10 +6293,7 @@ export class HighlightDirective {
62936293
);
62946294

62956295
// 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-
);
6296+
assert!(!result.code.contains("@Input()"), "@Input decorator must be removed from class body");
63006297
assert!(
63016298
!result.code.contains("@Output()"),
63026299
"@Output decorator must be removed from class body"
@@ -6307,20 +6304,28 @@ export class HighlightDirective {
63076304

63086305
#[test]
63096306
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.
6307+
// Angular-aligned union type behavior for ctorParameters.
6308+
// Angular's typeReferenceToExpression filters ONLY `null` literal types.
6309+
// If exactly one non-null type remains, it resolves; otherwise unresolvable.
6310+
//
6311+
// `T | null` → resolves to T (1 non-null type)
6312+
// `undefined | T` → unresolvable (2 non-null types: undefined + T)
6313+
// `null | undefined | T` → unresolvable (2 non-null types: undefined + T)
6314+
//
6315+
// See: angular/packages/compiler-cli/src/ngtsc/transform/jit/src/downlevel_decorators_transform.ts
63136316
let allocator = Allocator::default();
63146317
let source = r#"
63156318
import { Component } from '@angular/core';
63166319
import { ServiceA } from './a.service';
63176320
import { ServiceB } from './b.service';
6321+
import { ServiceC } from './c.service';
63186322
63196323
@Component({ selector: 'test', template: '' })
63206324
export class TestComponent {
63216325
constructor(
63226326
svcA: undefined | ServiceA,
63236327
svcB: null | undefined | ServiceB,
6328+
svcC: ServiceC | null,
63246329
) {}
63256330
}
63266331
"#;
@@ -6329,22 +6334,23 @@ export class TestComponent {
63296334
let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None);
63306335
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
63316336

6332-
// Both types must be correctly extracted despite union with undefined/null
6337+
// `ServiceC | null` resolves correctly (1 non-null type)
63336338
assert!(
6334-
result.code.contains("type: ServiceA"),
6335-
"ctorParameters should resolve 'undefined | ServiceA' to ServiceA. Got:\n{}",
6339+
result.code.contains("type: ServiceC"),
6340+
"ctorParameters should resolve 'ServiceC | null' to ServiceC. Got:\n{}",
63366341
result.code
63376342
);
6343+
6344+
// `undefined | ServiceA` and `null | undefined | ServiceB` are unresolvable per Angular spec
6345+
// (2 non-null types remain after filtering null)
63386346
assert!(
6339-
result.code.contains("type: ServiceB"),
6340-
"ctorParameters should resolve 'null | undefined | ServiceB' to ServiceB. Got:\n{}",
6347+
!result.code.contains("type: ServiceA"),
6348+
"ctorParameters must not resolve 'undefined | ServiceA' (2 non-null types). Got:\n{}",
63416349
result.code
63426350
);
6343-
6344-
// Should NOT emit 'type: undefined' for either
63456351
assert!(
6346-
!result.code.contains("type: undefined"),
6347-
"ctorParameters must not emit 'type: undefined' for resolvable union types. Got:\n{}",
6352+
!result.code.contains("type: ServiceB"),
6353+
"ctorParameters must not resolve 'null | undefined | ServiceB' (2 non-null types). Got:\n{}",
63486354
result.code
63496355
);
63506356

crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
---
22
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
assertion_line: 6360
34
expression: result.code
45
---
6+
57
import { Component } from '@angular/core';
68
import { ServiceA } from './a.service';
79
import { ServiceB } from './b.service';
10+
import { ServiceC } from './c.service';
811
import { __decorate } from "tslib";
912

1013
let TestComponent = class TestComponent {
1114
constructor(
1215
svcA: undefined | ServiceA,
1316
svcB: null | undefined | ServiceB,
17+
svcC: ServiceC | null,
1418
) {}
1519

1620
static ctorParameters = () => [
17-
{ type: ServiceA },
18-
{ type: ServiceB }
21+
{ type: undefined },
22+
{ type: undefined },
23+
{ type: ServiceC }
1924
];
2025
};
2126
TestComponent = __decorate([

0 commit comments

Comments
 (0)