Summary
Angular 22 changed the runtime semantics of the safe-navigation operator ?. in template expressions: a?.b now yields undefined when a is null/undefined, instead of null. The reference compiler exposes this via a legacyOptionalChaining compile-time flag and emits version-aware code. OXC hardcodes the legacy == null path, so projects compiled by OXC against Angular 22+ get the wrong runtime behavior for any expression involving ?..
This is time-bombed for the v22 release: every user upgrading hits it.
Reproduction
<div>{{ user?.name | json }}</div>
When user is undefined on Angular 22+:
- Expected output: undefined
- OXC-compiled output: null
Any downstream operator that distinguishes null from undefined (??, type-narrowing in templates, json pipes producing null vs nothing) sees the wrong value.
Reference behavior
Upstream 2896c93cc1 feat(compiler): Angular expressions with optional chaining returns undefined:
- packages/compiler/src/template/pipeline/src/phases/expand_safe_reads.ts — restructured around a legacyOptionalChaining flag
- packages/compiler/src/legacy_optional_chaining_default.ts — central default
- packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts, directive/src/handler.ts, directive/src/shared.ts — wired the flag through component/directive compilation
- packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts — added the public compiler option
Default: legacy (matches v21 behavior) on Angular < 22, modern (undefined) on Angular ≥ 22. Users can opt back into legacy via the legacyOptionalChaining tsconfig option, or wrap a single expression with the $null(...) magic function.
Current behavior
crates/oxc_angular_compiler/src/pipeline/phases/expand_safe_reads.rs:1-46 hardcodes the legacy SafeTernaryExpr { guard, expr } → (guard == null ? null : expr). No flag, no version detection, no $null escape hatch.
Required work
- Add a legacy_optional_chaining field to TransformOptions (NAPI + Rust). Default behavior:
- When angularVersion is provided and major < 22, default to true (legacy).
- When angularVersion is ≥ 22, default to false (modern → undefined).
- When angularVersion is unknown, default to true (safest — matches Angular's own conservative fallback for partial-compiled libs targeting old versions).
- Wire the flag through to expand_safe_reads.rs — emit the modern form when the flag is off (guard == null ? undefined : expr).
- Support the $null(...) magic function — when an expression is wrapped in $null(...), force the legacy form for that subtree. This is the user's escape hatch when they need null semantics on a modern target.
- Apply the same flag to partial-compiled output — the linker reads legacyOptionalChaining from R3DeclareDirectiveFacade / R3DeclareComponentFacade (added at packages/compiler/src/compiler_facade_interface.ts:227,281). If OXC ever emits partial output, this field must be threaded through.
Why this matters
This is the kind of bug that ships silently and only surfaces when a user upgrades and a previously-working template starts misbehaving. The change is in the runtime contract — a user can't work around it without recompiling.
Reference
- Upstream commit: 2896c93cc1
- Public option name: legacyOptionalChaining (in tsconfig.json angularCompilerOptions)
- Facade field: R3DirectiveMetadataFacade.legacyOptionalChaining (packages/compiler/src/compiler_facade_interface.ts:227)
- OXC site: crates/oxc_angular_compiler/src/pipeline/phases/expand_safe_reads.rs
Summary
Angular 22 changed the runtime semantics of the safe-navigation operator ?. in template expressions: a?.b now yields undefined when a is null/undefined, instead of null. The reference compiler exposes this via a legacyOptionalChaining compile-time flag and emits version-aware code. OXC hardcodes the legacy == null path, so projects compiled by OXC against Angular 22+ get the wrong runtime behavior for any expression involving ?..
This is time-bombed for the v22 release: every user upgrading hits it.
Reproduction
When user is undefined on Angular 22+:
Any downstream operator that distinguishes null from undefined (??, type-narrowing in templates, json pipes producing null vs nothing) sees the wrong value.
Reference behavior
Upstream 2896c93cc1 feat(compiler): Angular expressions with optional chaining returns undefined:
Default: legacy (matches v21 behavior) on Angular < 22, modern (undefined) on Angular ≥ 22. Users can opt back into legacy via the legacyOptionalChaining tsconfig option, or wrap a single expression with the $null(...) magic function.
Current behavior
crates/oxc_angular_compiler/src/pipeline/phases/expand_safe_reads.rs:1-46 hardcodes the legacy SafeTernaryExpr { guard, expr } → (guard == null ? null : expr). No flag, no version detection, no $null escape hatch.
Required work
Why this matters
This is the kind of bug that ships silently and only surfaces when a user upgrades and a previously-working template starts misbehaving. The change is in the runtime contract — a user can't work around it without recompiling.
Reference