Skip to content

Commit 85c1719

Browse files
arturovtdylhunn
authored andcommitted
fix(zone.js): do not mutate event listener options (may be readonly) (#55796)
Prior to this commit, event listener options were mutated directly, for example, `options.signal = undefined` or `options.once = false`. This issue arises in apps using third-party libraries where the responsibility lies with the library provider. Some libraries, like WalletConnect, pass an abort controller as `addEventListener` options. Because the abort controller has the `signal` property, this is a valid case. Thus, mutating options would throw an error since `signal` is a readonly property. Even though zone.js is being deprecated as Angular moves towards zoneless change detection, this fix is necessary for apps that still use zone.js and cannot migrate to zoneless change detection because they rely on third-party libraries and are not responsible for the code used in them. Closes #54142 PR Close #55796
1 parent a0690fe commit 85c1719

File tree

2 files changed

+106
-15
lines changed

2 files changed

+106
-15
lines changed

packages/zone.js/lib/common/events.ts

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ import {
2626
interface EventTaskData extends TaskData {
2727
// use global callback or not
2828
readonly useG?: boolean;
29+
taskData?: any;
30+
removeAbortListener?: VoidFunction | null;
31+
}
32+
33+
/** @internal **/
34+
interface InternalTaskData {
35+
// This is used internally to avoid duplicating event listeners on
36+
// the same target when the event name is the same, such as when
37+
// `addEventListener` is called multiple times on the `document`
38+
// for the `keydown` event.
39+
isExisting?: boolean;
40+
// `target` is the actual event target on which `addEventListener`
41+
// is being called.
42+
target?: any;
43+
eventName?: string;
44+
capture?: boolean;
45+
// Not changing the type to avoid any regressions.
46+
options?: any; // boolean | AddEventListenerOptions
2947
}
3048

3149
let passiveSupported = false;
@@ -247,7 +265,7 @@ export function patchEventTarget(
247265

248266
// a shared global taskData to pass data for scheduleEventTask
249267
// so we do not need to create a new object just for pass some data
250-
const taskData: any = {};
268+
const taskData: InternalTaskData = {};
251269

252270
const nativeAddEventListener = (proto[zoneSymbolAddEventListener] = proto[ADD_EVENT_LISTENER]);
253271
const nativeRemoveEventListener = (proto[zoneSymbol(REMOVE_EVENT_LISTENER)] =
@@ -386,6 +404,30 @@ export function patchEventTarget(
386404
const unpatchedEvents: string[] = (Zone as any)[zoneSymbol('UNPATCHED_EVENTS')];
387405
const passiveEvents: string[] = _global[zoneSymbol('PASSIVE_EVENTS')];
388406

407+
function copyEventListenerOptions(options: any) {
408+
if (typeof options === 'object' && options !== null) {
409+
// We need to destructure the target `options` object since it may
410+
// be frozen or sealed (possibly provided implicitly by a third-party
411+
// library), or its properties may be readonly.
412+
const newOptions: any = {...options};
413+
// The `signal` option was recently introduced, which caused regressions in
414+
// third-party scenarios where `AbortController` was directly provided to
415+
// `addEventListener` as options. For instance, in cases like
416+
// `document.addEventListener('keydown', callback, abortControllerInstance)`,
417+
// which is valid because `AbortController` includes a `signal` getter, spreading
418+
// `{...options}` wouldn't copy the `signal`. Additionally, using `Object.create`
419+
// isn't feasible since `AbortController` is a built-in object type, and attempting
420+
// to create a new object directly with it as the prototype might result in
421+
// unexpected behavior.
422+
if (options.signal) {
423+
newOptions.signal = options.signal;
424+
}
425+
return newOptions;
426+
}
427+
428+
return options;
429+
}
430+
389431
const makeAddListener = function (
390432
nativeListener: any,
391433
addSource: string,
@@ -426,7 +468,7 @@ export function patchEventTarget(
426468

427469
const passive =
428470
passiveSupported && !!passiveEvents && passiveEvents.indexOf(eventName) !== -1;
429-
const options = buildEventListenerOptions(arguments[2], passive);
471+
const options = copyEventListenerOptions(buildEventListenerOptions(arguments[2], passive));
430472
const signal: AbortSignal | undefined = options?.signal;
431473
if (signal?.aborted) {
432474
// the signal is an aborted one, just return without attaching the event listener.
@@ -484,13 +526,18 @@ export function patchEventTarget(
484526
addSource +
485527
(eventNameToString ? eventNameToString(eventName) : eventName);
486528
}
487-
// do not create a new object as task.data to pass those things
488-
// just use the global shared one
529+
530+
// In the code below, `options` should no longer be reassigned; instead, it
531+
// should only be mutated. This is because we pass that object to the native
532+
// `addEventListener`.
533+
// It's generally recommended to use the same object reference for options.
534+
// This ensures consistency and avoids potential issues.
489535
taskData.options = options;
536+
490537
if (once) {
491-
// if addEventListener with once options, we don't pass it to
492-
// native addEventListener, instead we keep the once setting
493-
// and handle ourselves.
538+
// When using `addEventListener` with the `once` option, we don't pass
539+
// the `once` option directly to the native `addEventListener` method.
540+
// Instead, we keep the `once` setting and handle it ourselves.
494541
taskData.options.once = false;
495542
}
496543
taskData.target = target;
@@ -502,15 +549,20 @@ export function patchEventTarget(
502549

503550
// keep taskData into data to allow onScheduleEventTask to access the task information
504551
if (data) {
505-
(data as any).taskData = taskData;
552+
data.taskData = taskData;
506553
}
507554

508555
if (signal) {
509-
// if addEventListener with signal options, we don't pass it to
510-
// native addEventListener, instead we keep the signal setting
511-
// and handle ourselves.
556+
// When using `addEventListener` with the `signal` option, we don't pass
557+
// the `signal` option directly to the native `addEventListener` method.
558+
// Instead, we keep the `signal` setting and handle it ourselves.
512559
taskData.options.signal = undefined;
513560
}
561+
562+
// The `scheduleEventTask` function will ultimately call `customScheduleGlobal`,
563+
// which in turn calls the native `addEventListener`. This is why `taskData.options`
564+
// is updated before scheduling the task, as `customScheduleGlobal` uses
565+
// `taskData.options` to pass it to the native `addEventListener`.
514566
const task: any = zone.scheduleEventTask(
515567
source,
516568
delegate,
@@ -532,7 +584,7 @@ export function patchEventTarget(
532584
// `task` object even after it goes out of scope, preventing `task` from being garbage
533585
// collected.
534586
if (data) {
535-
(data as any).removeAbortListener = () => signal.removeEventListener('abort', onAbort);
587+
data.removeAbortListener = () => signal.removeEventListener('abort', onAbort);
536588
}
537589
}
538590

@@ -542,13 +594,13 @@ export function patchEventTarget(
542594

543595
// need to clear up taskData because it is a global object
544596
if (data) {
545-
(data as any).taskData = null;
597+
data.taskData = null;
546598
}
547599

548600
// have to save those information to task in case
549601
// application may call task.zone.cancelTask() directly
550602
if (once) {
551-
options.once = true;
603+
taskData.options.once = true;
552604
}
553605
if (!(!passiveSupported && typeof task.options === 'boolean')) {
554606
// if not support passive, and we pass an option object
@@ -644,7 +696,7 @@ export function patchEventTarget(
644696

645697
// Note that `removeAllListeners` would ultimately call `removeEventListener`,
646698
// so we're safe to remove the abort listener only once here.
647-
const taskData = existingTask.data as any;
699+
const taskData = existingTask.data as EventTaskData;
648700
if (taskData?.removeAbortListener) {
649701
taskData.removeAbortListener();
650702
taskData.removeAbortListener = null;

packages/zone.js/test/browser/browser.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2237,6 +2237,45 @@ describe('Zone', function () {
22372237
expect(logs).toEqual(['click2', 'click4']);
22382238
});
22392239

2240+
// https://github.com/angular/angular/issues/54831
2241+
// https://github.com/angular/angular/issues/54142
2242+
it('should support passing `AbortController` directly to `addEventListener`', function () {
2243+
let logs: string[] = [];
2244+
const ac = new AbortController();
2245+
2246+
button.addEventListener(
2247+
'click',
2248+
function () {
2249+
logs.push('click1');
2250+
},
2251+
ac,
2252+
);
2253+
button.addEventListener('click', function () {
2254+
logs.push('click2');
2255+
});
2256+
button.addEventListener(
2257+
'click',
2258+
function () {
2259+
logs.push('click3');
2260+
},
2261+
ac,
2262+
);
2263+
let listeners = button.eventListeners!('click');
2264+
expect(listeners.length).toBe(3);
2265+
2266+
button.dispatchEvent(clickEvent);
2267+
expect(logs.length).toBe(3);
2268+
expect(logs).toEqual(['click1', 'click2', 'click3']);
2269+
ac.abort();
2270+
logs = [];
2271+
2272+
listeners = button.eventListeners!('click');
2273+
button.dispatchEvent(clickEvent);
2274+
expect(logs.length).toBe(1);
2275+
expect(listeners.length).toBe(1);
2276+
expect(logs).toEqual(['click2']);
2277+
});
2278+
22402279
it('should not add event listeners with aborted signal', function () {
22412280
let logs: string[] = [];
22422281

0 commit comments

Comments
 (0)