Skip to content

Commit f7382be

Browse files
fix(safe-nav): version-aware optional chaining for Angular v22+ (#317) (#330)
Angular v22 changed the safe-navigation operator (`?.`) in template expressions to yield `undefined` via native optional chaining, gated by the `legacyOptionalChaining` compiler option. OXC unconditionally emitted the legacy `== null ? null` ternary, so v22+ projects got the wrong runtime value for any `?.` expression. Changes: - Add `legacyOptionalChaining` to `TransformOptions` (NAPI + Rust) and thread it through ingest into the compilation jobs. The effective default is derived from `angularVersion`: legacy for < v22, modern (native `?.`) for >= v22, and legacy when the version is unknown (matches Angular's conservative fallback). - Add an `optional` flag to the resolved IR read/call nodes (`ResolvedPropertyRead`/`ResolvedKeyedRead`/`ResolvedCall`) and pass it through reify so it renders as native `?.` / `?.[]` / `?.()`. - Rewrite `expand_safe_reads` to branch per node: legacy builds the `SafeTernary` (`== null ? null`); modern rewrites each safe access into the equivalent optional resolved read (no temporaries needed). - Support the `$safeNavigationMigration(...)` escape hatch: a wrapped subtree is forced back to legacy null semantics even on a modern target, and the wrapper is stripped. Two deviations from the issue text, both to match the reference compiler (angular/angular@2896c93cc1): - The modern form is native optional chaining (`ctx.user?.name`), not the `== null ? undefined` ternary the issue described. Both yield `undefined` at runtime; native `?.` matches Angular's emitted output. - The magic function shipped in v22 is `$safeNavigationMigration(...)`, not `$null(...)` (the commit message named `$null` but the code renamed it). Partial/linker output keeps legacy semantics for now; threading the facade field through partial emit is deferred (issue required-work #4). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed4be8c commit f7382be

27 files changed

Lines changed: 639 additions & 23 deletions

crates/oxc_angular_compiler/src/component/metadata.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ impl AngularVersion {
7272
self.major >= 22
7373
}
7474

75+
/// Check if this version uses modern optional-chaining semantics (v22.0.0+).
76+
///
77+
/// Angular v22 changed the safe-navigation operator (`?.`) in template
78+
/// expressions to yield `undefined` (native optional chaining) instead of the
79+
/// legacy `null`. Earlier versions default to the legacy `== null ? null`
80+
/// expansion. Users can opt back into legacy behavior with the
81+
/// `legacyOptionalChaining` compiler option, or per-expression by wrapping it
82+
/// in the `$safeNavigationMigration(...)` magic function.
83+
///
84+
/// See `angular/angular@2896c93cc1`.
85+
pub fn supports_modern_optional_chaining(&self) -> bool {
86+
self.major >= 22
87+
}
88+
7589
/// Check if this version's runtime supports chained query instructions
7690
/// (`ɵɵviewQuery(p1)(p2)`, `ɵɵcontentQuerySignal(...)(...)`).
7791
///

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ pub struct TransformOptions {
110110
/// When `None`, assumes latest Angular version (v19+ behavior).
111111
pub angular_version: Option<AngularVersion>,
112112

113+
/// Override for the `legacyOptionalChaining` Angular compiler option.
114+
///
115+
/// Controls how the safe-navigation operator (`?.`) in template expressions is
116+
/// emitted. When `Some(true)`, always uses the legacy `== null ? null` ternary;
117+
/// when `Some(false)`, always emits native optional chaining (yielding
118+
/// `undefined`). When `None`, the default is derived from `angular_version`
119+
/// (legacy for < v22, modern for >= v22, legacy when the version is unknown).
120+
///
121+
/// See `angular/angular@2896c93cc1`.
122+
pub legacy_optional_chaining: Option<bool>,
123+
113124
// Component metadata overrides for template-only compilation.
114125
// These allow the build tool to pass component metadata when compiling
115126
// templates in isolation (e.g., for testing or compare tool).
@@ -227,8 +238,9 @@ impl Default for TransformOptions {
227238
jit: false,
228239
hmr: false,
229240
advanced_optimizations: false,
230-
i18n_use_external_ids: true, // Angular's JIT default
231-
angular_version: None, // None means assume latest (v19+ behavior)
241+
i18n_use_external_ids: true, // Angular's JIT default
242+
angular_version: None, // None means assume latest (v19+ behavior)
243+
legacy_optional_chaining: None, // None: derive default from angular_version
232244
// Metadata overrides default to None (use extracted/default values)
233245
selector: None,
234246
standalone: None,
@@ -3764,6 +3776,7 @@ fn compile_component_full<'a>(
37643776
pool_starting_index,
37653777
// Pass Angular version for feature-gated instruction selection
37663778
angular_version: options.angular_version,
3779+
legacy_optional_chaining: options.legacy_optional_chaining,
37673780
};
37683781

37693782
let mut job = ingest_component_with_options(
@@ -3827,6 +3840,7 @@ fn compile_component_full<'a>(
38273840
metadata,
38283841
template_pool_index,
38293842
options.angular_version,
3843+
options.legacy_optional_chaining,
38303844
);
38313845

38323846
// Extract the result and update pool index if host bindings were compiled
@@ -4217,6 +4231,7 @@ pub fn compile_template_to_js_with_options<'a>(
42174231
all_deferrable_deps_fn: None,
42184232
pool_starting_index: 0, // Standalone template compilation starts from 0
42194233
angular_version: options.angular_version,
4234+
legacy_optional_chaining: options.legacy_optional_chaining,
42204235
};
42214236

42224237
// Stage 3-5: Ingest and compile
@@ -4271,6 +4286,7 @@ pub fn compile_template_to_js_with_options<'a>(
42714286
options.selector.as_deref(),
42724287
host_pool_starting_index,
42734288
options.angular_version,
4289+
options.legacy_optional_chaining,
42744290
) {
42754291
// Add host binding pool declarations (pure functions, etc.)
42764292
for decl in host_result.declarations {
@@ -4390,6 +4406,7 @@ pub fn compile_template_for_hmr<'a>(
43904406
all_deferrable_deps_fn: None,
43914407
pool_starting_index: 0, // HMR template compilation starts from 0
43924408
angular_version: options.angular_version,
4409+
legacy_optional_chaining: options.legacy_optional_chaining,
43934410
};
43944411

43954412
// Stage 3-5: Ingest and compile
@@ -4535,6 +4552,7 @@ fn compile_component_host_bindings<'a>(
45354552
metadata: &ComponentMetadata<'a>,
45364553
pool_starting_index: u32,
45374554
angular_version: Option<AngularVersion>,
4555+
legacy_optional_chaining: Option<bool>,
45384556
) -> Option<HostBindingCompilationOutput<'a>> {
45394557
let host = metadata.host.as_ref()?;
45404558

@@ -4558,8 +4576,13 @@ fn compile_component_host_bindings<'a>(
45584576

45594577
// Ingest and compile the host bindings with the pool starting index
45604578
// This ensures constant names continue from where template compilation left off
4561-
let mut job =
4562-
ingest_host_binding_with_version(allocator, input, pool_starting_index, angular_version);
4579+
let mut job = ingest_host_binding_with_version(
4580+
allocator,
4581+
input,
4582+
pool_starting_index,
4583+
angular_version,
4584+
legacy_optional_chaining,
4585+
);
45634586
let result = compile_host_bindings(&mut job);
45644587

45654588
// Get the next pool index after host binding compilation
@@ -4883,6 +4906,7 @@ fn compile_host_bindings_from_input<'a>(
48834906
selector: Option<&str>,
48844907
pool_starting_index: u32,
48854908
angular_version: Option<crate::AngularVersion>,
4909+
legacy_optional_chaining: Option<bool>,
48864910
) -> Option<HostBindingCompilationResult<'a>> {
48874911
use oxc_allocator::FromIn;
48884912

@@ -4908,8 +4932,13 @@ fn compile_host_bindings_from_input<'a>(
49084932
// Convert to HostBindingInput and compile
49094933
let input =
49104934
convert_host_metadata_to_input(allocator, &host, component_name_atom, component_selector);
4911-
let mut job =
4912-
ingest_host_binding_with_version(allocator, input, pool_starting_index, angular_version);
4935+
let mut job = ingest_host_binding_with_version(
4936+
allocator,
4937+
input,
4938+
pool_starting_index,
4939+
angular_version,
4940+
legacy_optional_chaining,
4941+
);
49134942
let result = compile_host_bindings(&mut job);
49144943

49154944
Some(result)
@@ -4945,6 +4974,7 @@ pub fn compile_host_bindings_for_linker(
49454974
selector,
49464975
pool_starting_index,
49474976
None, // Linker always targets latest Angular version
4977+
None, // legacyOptionalChaining: derive from (absent) version
49484978
)?;
49494979

49504980
let emitter = JsEmitter::new();
@@ -5064,6 +5094,7 @@ pub fn compile_template_for_linker<'a>(
50645094
all_deferrable_deps_fn: None,
50655095
pool_starting_index: 0,
50665096
angular_version: None,
5097+
legacy_optional_chaining: None,
50675098
};
50685099

50695100
let component_name_atom = Ident::from_in(component_name, allocator);

crates/oxc_angular_compiler/src/ir/enums.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,9 @@ pub enum ExpressionKind {
264264
ArrowFunction,
265265
/// Parenthesized expression.
266266
Parenthesized,
267+
/// `$safeNavigationMigration(...)` wrapper marking its subtree for legacy
268+
/// safe-read semantics (removed by `expandSafeReads`).
269+
SafeNavigationMigration,
267270
}
268271

269272
/// Flags for semantic variables.

crates/oxc_angular_compiler/src/ir/expression.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ impl VisitorContextFlag {
5050
pub const IN_CHILD_OPERATION: Self = Self(0b0001);
5151
/// Inside an arrow function operation.
5252
pub const IN_ARROW_FUNCTION_OPERATION: Self = Self(0b0010);
53+
/// Inside a `$safeNavigationMigration(...)` wrapper. Safe reads in this subtree
54+
/// expand under legacy (`== null ? null`) semantics even when the compilation
55+
/// targets native optional chaining.
56+
///
57+
/// Mirrors Angular's `VisitorContextFlag.InSafeNavigationMigration` (`0b0100`).
58+
pub const IN_SAFE_NAVIGATION_MIGRATION: Self = Self(0b0100);
5359

5460
/// Check if a flag is set.
5561
pub fn contains(self, other: Self) -> bool {
@@ -153,6 +159,10 @@ pub enum IrExpression<'a> {
153159
/// Safe property read with resolved receiver (created during name resolution).
154160
/// Used when an expression like `item?.name` has the receiver resolved to a variable.
155161
ResolvedSafePropertyRead(Box<'a, ResolvedSafePropertyReadExpr<'a>>),
162+
/// `$safeNavigationMigration(...)` wrapper marking its subtree for legacy
163+
/// (`== null ? null`) safe-read semantics. Created by the
164+
/// `removeSafeNavigationMigration` phase and removed by `expandSafeReads`.
165+
SafeNavigationMigration(Box<'a, SafeNavigationMigrationExpr<'a>>),
156166
/// Derived literal array for pure function bodies.
157167
/// Contains IrExpression entries that can include PureFunctionParameter references.
158168
DerivedLiteralArray(Box<'a, DerivedLiteralArrayExpr<'a>>),
@@ -260,6 +270,7 @@ impl<'a> IrExpression<'a> {
260270
// ArrowFunction is an arrow function expression
261271
IrExpression::ArrowFunction(_) => ExpressionKind::ArrowFunction,
262272
IrExpression::Parenthesized(_) => ExpressionKind::Parenthesized,
273+
IrExpression::SafeNavigationMigration(_) => ExpressionKind::SafeNavigationMigration,
263274
}
264275
}
265276
}
@@ -592,6 +603,7 @@ impl<'a> IrExpression<'a> {
592603
ResolvedPropertyReadExpr {
593604
receiver: Box::new_in(e.receiver.clone_in(allocator), allocator),
594605
name: e.name.clone(),
606+
optional: e.optional,
595607
source_span: e.source_span,
596608
},
597609
allocator,
@@ -615,6 +627,7 @@ impl<'a> IrExpression<'a> {
615627
ResolvedCallExpr {
616628
receiver: Box::new_in(e.receiver.clone_in(allocator), allocator),
617629
args,
630+
optional: e.optional,
618631
source_span: e.source_span,
619632
},
620633
allocator,
@@ -624,6 +637,7 @@ impl<'a> IrExpression<'a> {
624637
ResolvedKeyedReadExpr {
625638
receiver: Box::new_in(e.receiver.clone_in(allocator), allocator),
626639
key: Box::new_in(e.key.clone_in(allocator), allocator),
640+
optional: e.optional,
627641
source_span: e.source_span,
628642
},
629643
allocator,
@@ -790,6 +804,15 @@ impl<'a> IrExpression<'a> {
790804
},
791805
allocator,
792806
)),
807+
IrExpression::SafeNavigationMigration(e) => {
808+
IrExpression::SafeNavigationMigration(Box::new_in(
809+
SafeNavigationMigrationExpr {
810+
expr: Box::new_in(e.expr.clone_in(allocator), allocator),
811+
source_span: e.source_span,
812+
},
813+
allocator,
814+
))
815+
}
793816
}
794817
}
795818
}
@@ -923,6 +946,31 @@ pub struct ResolvedPropertyReadExpr<'a> {
923946
pub receiver: Box<'a, IrExpression<'a>>,
924947
/// Property name to read.
925948
pub name: Ident<'a>,
949+
/// Whether to read via native optional chaining (`receiver?.name`).
950+
///
951+
/// Set to `true` when a safe property read (`a?.b`) is expanded under modern
952+
/// (Angular v22+) optional-chaining semantics, where `?.` yields `undefined`.
953+
/// Legacy reads and plain (non-safe) reads leave this `false`.
954+
pub optional: bool,
955+
/// Source span.
956+
pub source_span: Option<Span>,
957+
}
958+
959+
/// `$safeNavigationMigration(expr)` wrapper.
960+
///
961+
/// A magic marker that opts `expr`'s safe reads into legacy `== null ? null`
962+
/// semantics even when the compilation targets native optional chaining. Created
963+
/// by the `removeSafeNavigationMigration` phase from an unqualified
964+
/// `$safeNavigationMigration(...)` call, and unwrapped in `expandSafeReads` after
965+
/// its subtree has been expanded under the
966+
/// [`VisitorContextFlag::IN_SAFE_NAVIGATION_MIGRATION`] flag.
967+
///
968+
/// Ported from Angular's `SafeNavigationMigrationExpr` in
969+
/// `template/pipeline/ir/src/expression.ts`.
970+
#[derive(Debug)]
971+
pub struct SafeNavigationMigrationExpr<'a> {
972+
/// The wrapped expression.
973+
pub expr: Box<'a, IrExpression<'a>>,
926974
/// Source span.
927975
pub source_span: Option<Span>,
928976
}
@@ -955,6 +1003,12 @@ pub struct ResolvedCallExpr<'a> {
9551003
pub receiver: Box<'a, IrExpression<'a>>,
9561004
/// The call arguments (resolved or original).
9571005
pub args: Vec<'a, IrExpression<'a>>,
1006+
/// Whether to invoke via native optional chaining (`receiver?.()`).
1007+
///
1008+
/// Set to `true` when a safe call (`a?.()`) is expanded under modern
1009+
/// (Angular v22+) optional-chaining semantics. Legacy and plain calls
1010+
/// leave this `false`.
1011+
pub optional: bool,
9581012
/// Source span.
9591013
pub source_span: Option<Span>,
9601014
}
@@ -970,6 +1024,12 @@ pub struct ResolvedKeyedReadExpr<'a> {
9701024
pub receiver: Box<'a, IrExpression<'a>>,
9711025
/// The key expression (original, e.g., a number or expression).
9721026
pub key: Box<'a, IrExpression<'a>>,
1027+
/// Whether to read via native optional chaining (`receiver?.[key]`).
1028+
///
1029+
/// Set to `true` when a safe keyed read (`a?.[k]`) is expanded under modern
1030+
/// (Angular v22+) optional-chaining semantics. Legacy and plain keyed reads
1031+
/// leave this `false`.
1032+
pub optional: bool,
9731033
/// Source span.
9741034
pub source_span: Option<Span>,
9751035
}
@@ -1664,6 +1724,13 @@ pub fn transform_expressions_in_expression<'a, F>(
16641724
IrExpression::Parenthesized(e) => {
16651725
transform_expressions_in_expression(&mut e.expr, transform, flags);
16661726
}
1727+
IrExpression::SafeNavigationMigration(e) => {
1728+
// Mirror Angular's `SafeNavigationMigrationExpr.transformInternalExpressions`:
1729+
// the wrapped subtree is visited with the `InSafeNavigationMigration` flag so
1730+
// its safe reads expand under legacy `== null ? null` semantics.
1731+
let child_flags = flags.union(VisitorContextFlag::IN_SAFE_NAVIGATION_MIGRATION);
1732+
transform_expressions_in_expression(&mut e.expr, transform, child_flags);
1733+
}
16671734
// These expressions have no internal expressions
16681735
IrExpression::LexicalRead(_)
16691736
| IrExpression::Reference(_)
@@ -1844,6 +1911,10 @@ pub fn visit_expressions_in_expression<'a, F>(
18441911
IrExpression::Parenthesized(e) => {
18451912
visit_expressions_in_expression(&e.expr, visitor, flags);
18461913
}
1914+
IrExpression::SafeNavigationMigration(e) => {
1915+
let child_flags = flags.union(VisitorContextFlag::IN_SAFE_NAVIGATION_MIGRATION);
1916+
visit_expressions_in_expression(&e.expr, visitor, child_flags);
1917+
}
18471918
// These expressions have no internal expressions
18481919
IrExpression::LexicalRead(_)
18491920
| IrExpression::Reference(_)
@@ -2813,6 +2884,9 @@ pub fn vars_used_by_ir_expression(expr: &IrExpression<'_>) -> u32 {
28132884
vars_used_by_ir_expression(&b.left) + vars_used_by_ir_expression(&b.right)
28142885
}
28152886

2887+
// The $safeNavigationMigration wrapper consumes no slots itself; recurse.
2888+
IrExpression::SafeNavigationMigration(m) => vars_used_by_ir_expression(&m.expr),
2889+
28162890
// SafePropertyRead has a receiver expression
28172891
IrExpression::SafePropertyRead(spr) => vars_used_by_ir_expression(&spr.receiver),
28182892

0 commit comments

Comments
 (0)