Skip to content

Commit 4a3800a

Browse files
arturovtthePunderWoman
authored andcommitted
fix(zone.js): store remove abort listener on the scheduled task (#56160)
Prior to this commit, a memory leak occurred when the `abort` listener was not removed from the `AbortSignal`. We introduced a fix to remove the event listener, but it was erroneously stored on the `taskData`, which is a shared global object. Consequently, when something attempted to remove an event listener, it immediately removed the last stored abort listener. As a result, events would never be canceled. We have now rectified this by storing the remove abort listener function directly on the task itself. This adjustment ensures that the abort listener is tied only to the specific task. When the `abort` function is called, it cancels the task. Therefore, it is safe to associate the cleanup function directly with the task. Closes: #56148 PR Close #56160
1 parent 9aea8a0 commit 4a3800a

2 files changed

Lines changed: 122 additions & 27 deletions

File tree

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

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,10 @@ interface EventTaskData extends TaskData {
2727
// use global callback or not
2828
readonly useG?: boolean;
2929
taskData?: any;
30-
removeAbortListener?: VoidFunction | null;
3130
}
3231

3332
/** @internal **/
34-
interface InternalTaskData {
33+
interface InternalGlobalTaskData {
3534
// This is used internally to avoid duplicating event listeners on
3635
// the same target when the event name is the same, such as when
3736
// `addEventListener` is called multiple times on the `document`
@@ -46,6 +45,36 @@ interface InternalTaskData {
4645
options?: any; // boolean | AddEventListenerOptions
4746
}
4847

48+
/**
49+
* The `scheduleEventTask` function returns an `EventTask` object.
50+
* However, we also store some task-related information on the task
51+
* itself, such as the task target, for easy access when the task is
52+
* manually canceled or for other purposes. This internal storage is
53+
* used solely for enhancing our understanding of which properties are
54+
* being assigned to the task.
55+
*
56+
* @internal
57+
*/
58+
interface InternalEventTask extends EventTask {
59+
removeAbortListener?: VoidFunction | null;
60+
// `target` is the actual event target on which `addEventListener`
61+
// is being called for this specific task.
62+
target?: any;
63+
eventName?: string;
64+
capture?: boolean;
65+
// Not changing the type to avoid any regressions.
66+
options?: any; // boolean | AddEventListenerOptions
67+
// `isRemoved` is associated with a specific task and indicates whether
68+
// that task was canceled and removed from the event target to prevent
69+
// its invocation if dispatched later.
70+
isRemoved?: boolean;
71+
allRemoved?: boolean;
72+
}
73+
74+
// Note that passive event listeners are now supported by most modern browsers,
75+
// including Chrome, Firefox, Safari, and Edge. There's a pending change that
76+
// would remove support for legacy browsers by zone.js. Removing `passiveSupported`
77+
// from the codebase will reduce the final code size for existing apps that still use zone.js.
4978
let passiveSupported = false;
5079

5180
if (typeof window !== 'undefined') {
@@ -263,9 +292,15 @@ export function patchEventTarget(
263292

264293
const eventNameToString = patchOptions && patchOptions.eventNameToString;
265294

266-
// a shared global taskData to pass data for scheduleEventTask
267-
// so we do not need to create a new object just for pass some data
268-
const taskData: InternalTaskData = {};
295+
// We use a shared global `taskData` to pass data for `scheduleEventTask`,
296+
// eliminating the need to create a new object solely for passing data.
297+
// WARNING: This object has a static lifetime, meaning it is not created
298+
// each time `addEventListener` is called. It is instantiated only once
299+
// and captured by reference inside the `addEventListener` and
300+
// `removeEventListener` functions. Do not add any new properties to this
301+
// object, as doing so would necessitate maintaining the information
302+
// between `addEventListener` calls.
303+
const taskData: InternalGlobalTaskData = {};
269304

270305
const nativeAddEventListener = (proto[zoneSymbolAddEventListener] = proto[ADD_EVENT_LISTENER]);
271306
const nativeRemoveEventListener = (proto[zoneSymbol(REMOVE_EVENT_LISTENER)] =
@@ -322,12 +357,18 @@ export function patchEventTarget(
322357
);
323358
};
324359

325-
const customCancelGlobal = function (task: any) {
360+
/**
361+
* In the context of events and listeners, this function will be
362+
* called at the end by `cancelTask`, which, in turn, calls `task.cancelFn`.
363+
* Cancelling a task is primarily used to remove event listeners from
364+
* the task target.
365+
*/
366+
const customCancelGlobal = function (task: InternalEventTask) {
326367
// if task is not marked as isRemoved, this call is directly
327368
// from Zone.prototype.cancelTask, we should remove the task
328369
// from tasksList of target first
329370
if (!task.isRemoved) {
330-
const symbolEventNames = zoneSymbolEventNames[task.eventName];
371+
const symbolEventNames = zoneSymbolEventNames[task.eventName!];
331372
let symbolEventName;
332373
if (symbolEventNames) {
333374
symbolEventName = symbolEventNames[task.capture ? TRUE_STR : FALSE_STR];
@@ -340,6 +381,10 @@ export function patchEventTarget(
340381
existingTasks.splice(i, 1);
341382
// set isRemoved to data for faster invokeTask check
342383
task.isRemoved = true;
384+
if (task.removeAbortListener) {
385+
task.removeAbortListener();
386+
task.removeAbortListener = null;
387+
}
343388
if (existingTasks.length === 0) {
344389
// all tasks for the eventName + capture have gone,
345390
// remove globalZoneAwareCallback and remove the task cache from target
@@ -563,7 +608,7 @@ export function patchEventTarget(
563608
// which in turn calls the native `addEventListener`. This is why `taskData.options`
564609
// is updated before scheduling the task, as `customScheduleGlobal` uses
565610
// `taskData.options` to pass it to the native `addEventListener`.
566-
const task: any = zone.scheduleEventTask(
611+
const task: InternalEventTask = zone.scheduleEventTask(
567612
source,
568613
delegate,
569614
data,
@@ -583,9 +628,7 @@ export function patchEventTarget(
583628
// as it creates a closure that captures `task`. This closure retains a reference to the
584629
// `task` object even after it goes out of scope, preventing `task` from being garbage
585630
// collected.
586-
if (data) {
587-
data.removeAbortListener = () => signal.removeEventListener('abort', onAbort);
588-
}
631+
task.removeAbortListener = () => signal.removeEventListener('abort', onAbort);
589632
}
590633

591634
// should clear taskData.target to avoid memory leak
@@ -670,18 +713,22 @@ export function patchEventTarget(
670713
if (symbolEventNames) {
671714
symbolEventName = symbolEventNames[capture ? TRUE_STR : FALSE_STR];
672715
}
673-
const existingTasks: Task[] = symbolEventName && target[symbolEventName];
716+
const existingTasks: InternalEventTask[] = symbolEventName && target[symbolEventName];
717+
// `existingTasks` may not exist if the `addEventListener` was called before
718+
// it was patched by zone.js. Please refer to the attached issue for
719+
// clarification, particularly after the `if` condition, before calling
720+
// the native `removeEventListener`.
674721
if (existingTasks) {
675722
for (let i = 0; i < existingTasks.length; i++) {
676723
const existingTask = existingTasks[i];
677724
if (compare(existingTask, delegate)) {
678725
existingTasks.splice(i, 1);
679726
// set isRemoved to data for faster invokeTask check
680-
(existingTask as any).isRemoved = true;
727+
existingTask.isRemoved = true;
681728
if (existingTasks.length === 0) {
682729
// all tasks for the eventName + capture have gone,
683730
// remove globalZoneAwareCallback and remove the task cache from target
684-
(existingTask as any).allRemoved = true;
731+
existingTask.allRemoved = true;
685732
target[symbolEventName] = null;
686733
// in the target, we have an event listener which is added by on_property
687734
// such as target.onclick = function() {}, so we need to clear this internal
@@ -693,15 +740,11 @@ export function patchEventTarget(
693740
target[onPropertySymbol] = null;
694741
}
695742
}
696-
697-
// Note that `removeAllListeners` would ultimately call `removeEventListener`,
698-
// so we're safe to remove the abort listener only once here.
699-
const taskData = existingTask.data as EventTaskData;
700-
if (taskData?.removeAbortListener) {
701-
taskData.removeAbortListener();
702-
taskData.removeAbortListener = null;
703-
}
704-
743+
// In all other conditions, when `addEventListener` is called after being
744+
// patched by zone.js, we would always find an event task on the `EventTarget`.
745+
// This will trigger `cancelFn` on the `existingTask`, leading to `customCancelGlobal`,
746+
// which ultimately removes an event listener and cleans up the abort listener
747+
// (if an `AbortSignal` was provided when scheduling a task).
705748
existingTask.zone.cancelTask(existingTask);
706749
if (returnTarget) {
707750
return target;
@@ -710,10 +753,12 @@ export function patchEventTarget(
710753
}
711754
}
712755
}
713-
// issue 930, didn't find the event name or callback
714-
// from zone kept existingTasks, the callback maybe
715-
// added outside of zone, we need to call native removeEventListener
716-
// to try to remove it.
756+
// https://github.com/angular/zone.js/issues/930
757+
// We may encounter a situation where the `addEventListener` was
758+
// called on the event target before zone.js is loaded, resulting
759+
// in no task being stored on the event target due to its invocation
760+
// of the native implementation. In this scenario, we simply need to
761+
// invoke the native `removeEventListener`.
717762
return nativeRemoveEventListener.apply(this, arguments);
718763
};
719764

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2276,6 +2276,56 @@ describe('Zone', function () {
22762276
expect(logs).toEqual(['click2']);
22772277
});
22782278

2279+
// https://github.com/angular/angular/issues/56148
2280+
it('should store the remove abort listener on the task itself and not the task data', function () {
2281+
const logs: string[] = [];
2282+
const ac = new AbortController();
2283+
const onLoad = jasmine.createSpy();
2284+
2285+
// Note that we have to manually spy on the `removeEventListener`
2286+
// because zone.js calls the native `addEventListener` for an
2287+
// `AbortSignal`, which isn't patched by zone.js. Thus, calling
2288+
// `signal.eventListeners('abort')` would always return an empty list.
2289+
const removeEventListenerSpy = spyOn(
2290+
AbortSignal.prototype,
2291+
'removeEventListener',
2292+
).and.callThrough();
2293+
2294+
window.addEventListener('load', onLoad, {once: true});
2295+
2296+
button.addEventListener(
2297+
'click',
2298+
function () {
2299+
logs.push('click');
2300+
},
2301+
{signal: ac.signal},
2302+
);
2303+
2304+
// In this scenario, we must dispatch the load event because
2305+
// `{once:true}` options are provided. This action will remove
2306+
// the event listener on the `window` after the task is executed,
2307+
// effectively cancelling it. Previously, we encountered a regression
2308+
// where `removeAbortListener` was stored on the task data instead of
2309+
// the task itself. Consequently, it would remove the `abort` listener
2310+
// from the signal. As a result, calling `abort()` on the controller wouldn't
2311+
// cancel any events because there was no `abort` listener present.
2312+
window.dispatchEvent(new Event('load'));
2313+
expect(onLoad).toHaveBeenCalled();
2314+
2315+
// Assert that the `abort` event listener has NOT been removed.
2316+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(0);
2317+
2318+
// When calling abort, it should now remove the event listener
2319+
// from the button, thus dispatching events should not trigger the listener.
2320+
ac.abort();
2321+
2322+
button.dispatchEvent(clickEvent);
2323+
2324+
const listeners = button.eventListeners!('click');
2325+
expect(listeners.length).toBe(0);
2326+
expect(logs).toEqual([]);
2327+
});
2328+
22792329
it('should not add event listeners with aborted signal', function () {
22802330
let logs: string[] = [];
22812331

0 commit comments

Comments
 (0)