Skip to content

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

Description

@stefanwaldhauser

Which @angular/* package(s) are the source of the bug?

zone.js

Is this a regression?

Yes. Introduced in zone.js 0.14.7 (June 2024) 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.

Description

copyEventListenerOptions in packages/zone.js/lib/common/events.ts copies the caller's options object with {...options} before forwarding it to native addEventListener. Object spread copies only own enumerable data properties. The native call, by contrast, reads dictionary members via WebIDL — a plain [[Get]] per member, which honours accessors and ignores enumerability. The copy is therefore lossy in a way the native call is not.

signal was already special-cased after it broke WalletConnect (see #54142). passive, capture, and once were not, and are silently dropped in the same circumstances.

Observed in Chromium against zone.js 0.15.1:

option supplied as an accessor without zone.js with zone.js
capture: true capture phase (eventPhase === 1) bubbling phase (3)
once: true fires once fires every time
passive getter (feature-detection probe) getter invoked never invoked
same options as a plain object literal honoured honoured

Nothing throws in any case.

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.

Please provide a link to a minimal reproduction of the bug

Standalone HTML, no framework or build:

<script src="https://unpkg.com/zone.js@0.15.1/bundles/zone.umd.js"></script>
<script>
  let supported = false;
  window.addEventListener('probe', function () {},
    Object.defineProperty({}, 'passive', { get: () => { supported = true; return false; } }));
  console.log('passive detected:', supported);

  const inner = document.createElement('button');
  document.body.append(inner);
  document.body.addEventListener('click', e => console.log('phase:', e.eventPhase),
    Object.defineProperty({}, 'capture', { get: () => true }));
  inner.click();
</script>

Please provide the exception or error you saw

None — silent loss of options.

Please provide the environment you discovered this bug in (run `ng version`)

Reproduced against published zone.js 0.13.3 → 0.16.2 in headless Chromium.

Anything else?

The copy itself was the correct fix for #54142 (frozen/readonly options). The defect is that the copy is lossy, not that it exists — reverting to mutation would re-introduce #54142.

A fix is ready and will be sent as a PR that references this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: zonesIssues related to zone.jsgemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions