Skip to content

Commit 0652b29

Browse files
JiaLiPassionalxhub
authored andcommitted
fix(zone.js): setTimeout patch should clean tasksByHandleId cache. (#40586)
Close #40387 Currently zone.js patches `setTimeout` and keeps a `tasksByHandleId` map to keep `timerId` <-> `ZoneTask` relationship. This is needed so that when `clearTimeout(timerId)` is called, zone.js can find the associated `ZoneTask`. Now zone.js set the `tasksByHandleId` map in the `scheduleTask` function, but if the `setTimeout` is running in the `FakeAsyncZoneSpec` or any other `ZoneSpec` with `onScheduleTask` hooks. The `scheduleTask` in `timer` patch may not be invoked. For example: ``` fakeAsync(() => { setTimeout(() => {}); tick(); }); ``` In this case, the `timerId` kept in the `tasksByHandleId` map is not cleared. This is because the `FakeAsyncZoneSpec` in the `onScheduleTask` hook looks like this. ``` onScheduleTask(delegate, ..., task) { fakeAsyncScheduler.setTimeout(task); return task; } ``` Because `FakeAsyncZoneSpec` handles the task itself and it doesn't call `parentDelegate.onScheduleTask`, therefore the default `scheduleTask` in the `timer` patch is not invoked. In this commit, the cleanup logic is moved from `scheduleTask` to `setTimeout` patch entry to avoid the memory leak. PR Close #40586
1 parent d9e4d75 commit 0652b29

File tree

2 files changed

+63
-21
lines changed

2 files changed

+63
-21
lines changed

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

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,27 +29,9 @@ export function patchTimer(window: any, setName: string, cancelName: string, nam
2929

3030
function scheduleTask(task: Task) {
3131
const data = <TimerOptions>task.data;
32-
function timer(this: unknown) {
33-
try {
34-
task.invoke.apply(this, arguments);
35-
} finally {
36-
// issue-934, task will be cancelled
37-
// even it is a periodic task such as
38-
// setInterval
39-
if (!(task.data && task.data.isPeriodic)) {
40-
if (typeof data.handleId === 'number') {
41-
// in non-nodejs env, we remove timerId
42-
// from local cache
43-
delete tasksByHandleId[data.handleId];
44-
} else if (data.handleId) {
45-
// Node returns complex objects as handleIds
46-
// we remove task reference from timer object
47-
(data.handleId as any)[taskSymbol] = null;
48-
}
49-
}
50-
}
51-
}
52-
data.args[0] = timer;
32+
data.args[0] = function() {
33+
return task.invoke.apply(this, arguments);
34+
};
5335
data.handleId = setNative!.apply(window, data.args);
5436
return task;
5537
}
@@ -67,6 +49,32 @@ export function patchTimer(window: any, setName: string, cancelName: string, nam
6749
undefined,
6850
args: args
6951
};
52+
const callback = args[0];
53+
args[0] = function timer(this: unknown) {
54+
try {
55+
return callback.apply(this, arguments);
56+
} finally {
57+
// issue-934, task will be cancelled
58+
// even it is a periodic task such as
59+
// setInterval
60+
61+
// https://github.com/angular/angular/issues/40387
62+
// Cleanup tasksByHandleId should be handled before scheduleTask
63+
// Since some zoneSpec may intercept and doesn't trigger
64+
// scheduleFn(scheduleTask) provided here.
65+
if (!(options.isPeriodic)) {
66+
if (typeof options.handleId === 'number') {
67+
// in non-nodejs env, we remove timerId
68+
// from local cache
69+
delete tasksByHandleId[options.handleId];
70+
} else if (options.handleId) {
71+
// Node returns complex objects as handleIds
72+
// we remove task reference from timer object
73+
(options.handleId as any)[taskSymbol] = null;
74+
}
75+
}
76+
}
77+
};
7078
const task =
7179
scheduleMacroTaskWithCurrentZone(setName, args[0], options, scheduleTask, clearTask);
7280
if (!task) {

packages/zone.js/test/zone-spec/fake-async-test.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,40 @@ describe('FakeAsyncTestZoneSpec', () => {
289289
});
290290
});
291291

292+
it('should clear internal timerId cache', () => {
293+
let taskSpy: jasmine.Spy = jasmine.createSpy('taskGetState');
294+
fakeAsyncTestZone
295+
.fork({
296+
name: 'scheduleZone',
297+
onScheduleTask: (delegate: ZoneDelegate, curr: Zone, target: Zone, task: Task) => {
298+
(task as any)._state = task.state;
299+
Object.defineProperty(task, 'state', {
300+
configurable: true,
301+
enumerable: true,
302+
get: () => {
303+
taskSpy();
304+
return (task as any)._state;
305+
},
306+
set: (newState: string) => {
307+
(task as any)._state = newState;
308+
}
309+
});
310+
return delegate.scheduleTask(target, task);
311+
}
312+
})
313+
.run(() => {
314+
const id = setTimeout(() => {}, 0);
315+
testZoneSpec.tick();
316+
clearTimeout(id);
317+
// This is a hack way to test the timerId cache is cleaned or not
318+
// since the tasksByHandleId cache is an internal variable held by
319+
// zone.js timer patch, if the cache is not cleared, the code in `timer.ts`
320+
// will call `task.state` one more time to check whether to clear the
321+
// task or not, so here we use this count to check the issue is fixed or not
322+
// For details, please refer to https://github.com/angular/angular/issues/40387
323+
expect(taskSpy.calls.count()).toEqual(5);
324+
});
325+
});
292326
it('should pass arguments to setImmediate', ifEnvSupports('setImmediate', () => {
293327
fakeAsyncTestZone.run(() => {
294328
let value = 'genuine value';

0 commit comments

Comments
 (0)