Skip to content

fix(chaining): preserve optional chaining in chained bindings#382

Open
ashley-hunter wants to merge 1 commit into
voidzero-dev:mainfrom
ashley-hunter:chaining-fix
Open

fix(chaining): preserve optional chaining in chained bindings#382
ashley-hunter wants to merge 1 commit into
voidzero-dev:mainfrom
ashley-hunter:chaining-fix

Conversation

@ashley-hunter

Copy link
Copy Markdown
Collaborator

Summary

Fixes a bug where the ?. safe-navigation operator was dropped from property, attribute, class, style, and host bindings when an element had more than one binding.

When an element has multiple bindings, the chaining phase merges the consecutive instructions into a single chained call:

ɵɵproperty("a", x)("b", y)("c", z)

To build that chain it clones the arguments of every binding after the first. The clone hard-coded optional: false for property reads, keyed reads, and function invocations, so the optional-chaining flag was silently dropped on every binding except the first (the chain root, which is never cloned).

Impact

Under native optional chaining (Angular v22+), a template like:

<div [title]="a?.b" [subtitle]="c?.d" [tooltip]="e?.f"></div>

compiled to:

ɵɵproperty("title", ctx.a?.b)("subtitle", ctx.c.d)("tooltip", ctx.e.f)

The 2nd and 3rd bindings lost their guard (ctx.c.d instead of ctx.c?.d), throwing Cannot read properties of null/undefined at runtime whenever the receiver was nullish. Single-binding elements and the first binding of any chain were unaffected, which made it look intermittent.

The same drop applied to ?.[k] (keyed reads), ?.() (safe calls), and to host property bindings, which go through the same chaining/clone path.

Fix

Copy the real optional flag from the source node instead of forcing false in the chaining clone.

Notes

  • Legacy output (pre-v22) is unaffected. In legacy mode the guard is a (x == null ? null : x.y) ternary (a structural node cloned faithfully), not the optional flag, so the clone was already correct. Verified and pinned with a test.
  • Also adds comments explaining why the two-way binding write-back clone must keep its assignment target non-optional (a?.b = value is not a valid assignment target, so preserving the flag there would emit invalid JavaScript).

Tests

Adds regression tests covering:

  • Chained property bindings preserve ?. on every binding (not just the first)
  • ?.[k] and ?.() in chained bindings
  • Attribute, class, and style bindings
  • ?. nested inside larger expressions (!x?.y, chained x?.y?.z, ternary branches)
  • Host property bindings
  • Legacy (pre-v22) chained bindings keep their null-guard ternary
  • Two-way binding write-back target stays a plain, assignable read

Each new v22 test was confirmed to fail before the fix and pass after. Full test suite and conformance (1264/1264) pass with no snapshot changes.

When an element has multiple bindings, the chaining phase merges the
consecutive instructions into a single chained call, e.g.
`ɵɵproperty("a", x)("b", y)`. To do this it clones the arguments of
every binding after the first. That clone hard-coded `optional: false`
for property reads, keyed reads, and function invocations, which
silently dropped the `?.` optional-chaining flag on every binding
except the first.

Under native optional chaining (Angular v22+) this emitted `x.y`
instead of `x?.y`, `x[k]` instead of `x?.[k]`, and `fn()` instead of
`fn?.()`, throwing at runtime whenever the receiver was nullish. The
first binding was unaffected because it is the chain root and is never
cloned.

Copy the real `optional` flag from the source node instead of forcing
`false`. Legacy (pre-v22) output is unaffected: there the guard is a
`== null ? null` ternary rather than the `optional` flag, so the clone
was already correct.

Also documents why the two-way binding write-back clone
(`clone_output_expression`) must keep its assignment target
non-optional, since `a?.b = value` is not a valid assignment target.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant