Skip to content

fix(zone.js): preserve accessor and non-enumerable event listener options - #70432

Merged
mattrbeck merged 1 commit into
angular:mainfrom
stefanwaldhauser:fix/zone-js-copy-event-listener-options
Aug 31, 2026
Merged

mattrbeck merged 1 commit into
angular:mainfrom
stefanwaldhauser:fix/zone-js-copy-event-listener-options

Conversation

@stefanwaldhauser

@stefanwaldhauser stefanwaldhauser commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Checklist

PR Type

  • Bugfix

What is the current behavior?

Issue Number: #70431

Since zone.js 0.14.7 (June 2024, #55796), the patched addEventListener silently drops event listener options that are exposed as accessors or as non-enumerable properties.

copyEventListenerOptions copies the caller's options with {...options} before forwarding to native. Object spread copies only own enumerable data properties. The native call, by contrast, reads each dictionary member via WebIDL — a plain [[Get]], which invokes accessors and ignores enumerability. The copy is therefore lossy in a way the native call is not.

Observed in Chromium, zone.js 0.15.1:

option supplied as an accessor without zone.js with zone.js
capture: true capturing phase (1) bubbling phase (3)
once: true fires once fires every time
passive getter (feature-detection) getter invoked never invoked

Nothing throws in any case.

signal was already special-cased for AbortController.prototype.signal after #54142; the other three members were not.

The passive row is the one with ecosystem-wide fallout: it defeats the MDN feature test for passive-listener support, where the getter being read is the signal. That snippet is used verbatim by Highcharts (See: https://github.com/highcharts/highcharts/blob/69edf8952a04b9872b6510fbdacd7dff8b03970b/ts/Core/Globals.ts#L201). Making every listener they register non-passive. Including ones they explicitly intended to be passive.

What is the new behavior?

The copy is still made — that fix from #55796 is preserved (frozen/readonly options still work). Each recognised member (capture, once, passive, signal) is additionally read from the source object via [[Get]] when the spread didn't already produce it, which recovers non-enumerable and prototype accessors. The hasOwnProperty gate on the loop ensures no getter is invoked twice.

The call site is reordered to buildEventListenerOptions(copyEventListenerOptions(...)) so the passive-events code path ({...options, passive: true} in buildEventListenerOptions) also spreads a normalised data object rather than the caller's raw input.

Tests added in packages/zone.js/test/browser/browser.spec.ts cover:

Alternatives considered and rejected:

  • Spread, then unconditionally re-read the four members. Double-invokes enumerable accessors.
  • Descriptor-preserving copy (Object.defineProperties({}, Object.getOwnPropertyDescriptors(options))). Loses signal — it lives on AbortController.prototype, not as an own property.
  • Prototype-preserving copy (Object.create(Object.getPrototypeOf(options), Object.getOwnPropertyDescriptors(options))). Throws on built-ins: TypeError: Cannot read private member #signal from an object whose class did not declare it. This is the hazard the pre-existing code comment already warned about.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

Provenance: introduced by #55796 / 85c1719. Bisected: 0.13.3, 0.14.0, 0.14.4, 0.14.6 all behave correctly; 0.14.7 → 0.16.2 do not.

Fixes #70431.

@pullapprove
pullapprove Bot requested a review from kirjs August 27, 2026 14:03
@angular-robot angular-robot Bot added the area: zones Issues related to zone.js label Aug 27, 2026
@ngbot ngbot Bot added this to the Backlog milestone Aug 27, 2026
Comment thread packages/zone.js/lib/common/events.ts Resolved
@JeanMeche
JeanMeche requested review from alan-agius4 and removed request for kirjs August 27, 2026 14:10
Comment thread packages/zone.js/lib/common/events.ts Resolved
@stefanwaldhauser
stefanwaldhauser force-pushed the fix/zone-js-copy-event-listener-options branch 2 times, most recently from ef33cdd to 5bfb5a7 Compare August 27, 2026 14:17
@stefanwaldhauser

stefanwaldhauser commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Just as context, here is a minimal reproduction of the HighChart bug that lead me to this: https://jsfiddle.net/5ewv9gL7/.

Change to <script src="https://unpkg.com/[email protected]/bundles/zone.umd.js"></script> to <script src="https://unpkg.com/[email protected]/bundles/zone.umd.js"></script> in the fiddle to see the bug is gone and handlers are correctly registered as passive.

From there I looked at the HighChart logic https://github.com/highcharts/highcharts/blob/69edf8952a04b9872b6510fbdacd7dff8b03970b/ts/Core/Globals.ts#L201 that works in zone.js <0.14.7 but breaks in >=0.14.7

The logic is taken from here it seems https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md#feature-detection

Comment thread packages/zone.js/lib/common/events.ts Resolved
…ions

`copyEventListenerOptions` copied the caller's options with `{...options}`
before forwarding to the native `addEventListener`. Object spread only
copies own enumerable data properties, whereas the native call reads
each dictionary member via WebIDL — a plain `[[Get]]` per member, which
invokes accessors and ignores enumerability. The copy was therefore
lossy in a way the native call is not:

- `Object.defineProperty(opts, 'passive', { get })` (the shape used by
  MDN's passive-listener feature test) — the getter was never invoked,
  so libraries that use the feature test fall back to the legacy boolean
  and register every listener as non-passive.
- `Object.defineProperty(opts, 'capture', { get: () => true })` — the
  listener was silently registered on the bubbling phase.
- `Object.defineProperty(opts, 'once', { get: () => true })` — the
  listener fired on every dispatch.

`signal` was already special-cased for `AbortController.prototype.signal`
after angular#54142; that patch generalises the workaround to every recognised
member.

The copy itself was the correct fix for angular#54142 (frozen/readonly options)
and is preserved. The fix reads each recognised member from the source
via `[[Get]]` when the spread did not, which recovers accessors and
non-enumerable properties without double-invoking any getter. The list
of recognised members is hoisted to module scope so it isn't allocated
on every `patchEventTarget` invocation.

The call site is reordered to `buildEventListenerOptions(
copyEventListenerOptions(...))` so the passive-events code path also
spreads a normalised data object rather than the caller's raw input.

Fixes angular#70431

Co-authored-by: Matthieu Riegler <[email protected]>
@stefanwaldhauser
stefanwaldhauser force-pushed the fix/zone-js-copy-event-listener-options branch from 318d25c to 53722e3 Compare August 27, 2026 15:08
@JeanMeche JeanMeche added action: merge The PR is ready for merge by the caretaker target: patch This PR is targeted for the next patch release labels Aug 28, 2026
@mattrbeck
mattrbeck merged commit 4c4a705 into angular:main Aug 31, 2026
27 checks passed
@mattrbeck

Copy link
Copy Markdown
Member

This PR was merged into the repository. The changes were merged into the following branches:

@angular-automatic-lock-bot

Copy link
Copy Markdown

This pull request has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot Bot locked and limited conversation to collaborators Oct 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

action: merge The PR is ready for merge by the caretaker area: zones Issues related to zone.js target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

zone.js: patched addEventListener drops options exposed as accessors or non-enumerable properties

4 participants