Skip to content

Commit 22f9e45

Browse files
JiaLiPassionmhevery
authored andcommitted
fix(core): NgZone coaleascing options should trigger onStable correctly (#40540)
fix angular/components#21674 When setting `ngZoneRunCoalescing` to true, `onStable` is not emitted correctly. The reason is before this commit, the code looks like this ``` // Application code call `ngZone.run()` ngZone.run(() => {}); // step 1 // Inside NgZone, in the OnInvoke hook, NgZone try to delay the checkStable() function delayChangeDetectionForEvents(zone: NgZonePrivate) { if (zone.lastRequestAnimationFrameId !== -1) { // step 9 return; } zone.lastRequestAnimationFrameId = zone.nativeRequestAnimationFrame.call(global, () => { // step 2 if (!zone.fakeTopEventTask) { zone.fakeTopEventTask = Zone.root.scheduleEventTask('fakeTopEventTask', () => { zone.lastRequestAnimationFrameId = -1; // step 3 updateMicroTaskStatus(zone); // step 4 checkStable(zone); // step 6 }, undefined, () => {}, () => {}); } zone.fakeTopEventTask.invoke(); }); updateMicroTaskStatus(zone); } function updateMicroTaskStatus(zone: NgZonePrivate, ignoreCheckRAFId = false) { if (zone._hasPendingMicrotasks || ((zone.shouldCoalesceEventChangeDetection || zone.shouldCoalesceRunChangeDetection) && zone.lastRequestAnimationFrameId !== -1)) { // step 5 zone.hasPendingMicrotasks = true; } else { zone.hasPendingMicrotasks = false; } } function checkStable(zone: NgZonePrivate) { if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) { // step 7 try { zone._nesting++; zone.onMicrotaskEmpty.emit(null); ... } // application ref subscribe onMicroTaskEmpty ngZone.onMicroTaskEmpty.subscribe(() => { ngZone.run(() => { // step 8 tick(); }); }); ``` And the process is: 1. step 1: application call ngZone.run() 2. step 2: NgZone delay the checkStable() call in a requestAnimationFrame, and also set zone.lastRequestAnimationFrameId 3. step 3: Inside the requestAnimationFrame callback, reset zone.lastRequestAnimationFrameId first 4. step 4: update microTask status 5, step 5: if zone.lastRequestAnimationFrameId is -1, that means no microTask pending. 6. step 6: checkStable and trigger onMicrotaskEmpty emitter. 7. step 7: ApplicationRef subscribed onMicrotaskEmpty, so it will call another `ngZone.run()` to process tick() 8. step 8: And this new `ngZone.run()` will try to check `zone.lastRequestAnimationFrameId` in `step 9` when trying to delay the checkStable(), and since the zone.lastRequestAnimationFrameId is already reset to -1 in step 3, so this ngZone.run() will run into step 2 again. 9. And become a infinite loop..., so onStable is never emit In this commit, the `zone.lastRequestAnimationFrameId` reset is moved after `checkStable()` call. PR Close #40540
1 parent 88f8ddd commit 22f9e45

File tree

2 files changed

+72
-2
lines changed

2 files changed

+72
-2
lines changed

packages/core/src/zone/ng_zone.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export class NgZone {
148148
!shouldCoalesceRunChangeDetection && shouldCoalesceEventChangeDetection;
149149
self.shouldCoalesceRunChangeDetection = shouldCoalesceRunChangeDetection;
150150
self.lastRequestAnimationFrameId = -1;
151+
self.isCheckStableRunning = false;
151152
self.nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame;
152153
forkInnerZoneWithAngularBehavior(self);
153154
}
@@ -243,6 +244,17 @@ interface NgZonePrivate extends NgZone {
243244
hasPendingMacrotasks: boolean;
244245
hasPendingMicrotasks: boolean;
245246
lastRequestAnimationFrameId: number;
247+
/**
248+
* A flag to indicate if NgZone is currently inside
249+
* checkStable and to prevent re-entry. The flag is
250+
* needed because it is possible to invoke the change
251+
* detection from within change detection leading to
252+
* incorrect behavior.
253+
*
254+
* For detail, please refer here,
255+
* https://github.com/angular/angular/pull/40540
256+
*/
257+
isCheckStableRunning: boolean;
246258
isStable: boolean;
247259
/**
248260
* Optionally specify coalescing event change detections or not.
@@ -291,7 +303,9 @@ interface NgZonePrivate extends NgZone {
291303
}
292304

293305
function checkStable(zone: NgZonePrivate) {
294-
if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
306+
if (!zone.isCheckStableRunning && zone._nesting == 0 && !zone.hasPendingMicrotasks &&
307+
!zone.isStable) {
308+
zone.isCheckStableRunning = true;
295309
try {
296310
zone._nesting++;
297311
zone.onMicrotaskEmpty.emit(null);
@@ -304,12 +318,26 @@ function checkStable(zone: NgZonePrivate) {
304318
zone.isStable = true;
305319
}
306320
}
321+
zone.isCheckStableRunning = false;
307322
}
308323
}
309324
}
310325

311326
function delayChangeDetectionForEvents(zone: NgZonePrivate) {
312-
if (zone.lastRequestAnimationFrameId !== -1) {
327+
/**
328+
* We also need to check isCheckStableRunning here
329+
* Consider the following case with shouldCoalesceRunChangeDetection = true
330+
*
331+
* ngZone.run(() => {});
332+
* ngZone.run(() => {});
333+
*
334+
* We want the two `ngZone.run()` only trigger one change detection
335+
* when shouldCoalesceRunChangeDetection is true.
336+
* And because in this case, change detection run in async way(requestAnimationFrame),
337+
* so we also need to check the isCheckStableRunning here to prevent multiple
338+
* change detections.
339+
*/
340+
if (zone.isCheckStableRunning || zone.lastRequestAnimationFrameId !== -1) {
313341
return;
314342
}
315343
zone.lastRequestAnimationFrameId = zone.nativeRequestAnimationFrame.call(global, () => {

packages/core/test/zone/ng_zone_spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,7 @@ function commonTests() {
982982

983983
describe('shouldCoalesceEventChangeDetection = true, shouldCoalesceRunChangeDetection = false', () => {
984984
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
985+
let nativeSetTimeout: any = (global as any)[Zone.__symbol__('setTimeout')];
985986
if (!(global as any).requestAnimationFrame) {
986987
nativeRequestAnimationFrame = function(fn: Function) {
987988
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
@@ -1042,6 +1043,32 @@ function commonTests() {
10421043
});
10431044
});
10441045

1046+
it('should only emit onMicroTaskEmpty once within the same event loop for ngZone.run in onMicrotaskEmpty subscription',
1047+
(done: DoneFn) => {
1048+
const tasks: Task[] = [];
1049+
coalesceZone.onMicrotaskEmpty.subscribe(() => {
1050+
coalesceZone.run(() => {});
1051+
});
1052+
coalesceZone.run(() => {
1053+
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
1054+
logs.push('eventTask1');
1055+
}, undefined, () => {}));
1056+
});
1057+
coalesceZone.run(() => {
1058+
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
1059+
logs.push('eventTask2');
1060+
}, undefined, () => {}));
1061+
});
1062+
tasks.forEach(t => t.invoke());
1063+
expect(logs).toEqual(['microTask empty', 'microTask empty', 'eventTask1', 'eventTask2']);
1064+
nativeSetTimeout(() => {
1065+
expect(logs).toEqual([
1066+
'microTask empty', 'microTask empty', 'eventTask1', 'eventTask2', 'microTask empty'
1067+
]);
1068+
done();
1069+
}, 100);
1070+
});
1071+
10451072
it('should emit onMicroTaskEmpty once within the same event loop for not only event tasks, but event tasks are before other tasks',
10461073
(done: DoneFn) => {
10471074
const tasks: Task[] = [];
@@ -1094,6 +1121,7 @@ function commonTests() {
10941121

10951122
describe('shouldCoalesceRunChangeDetection = true', () => {
10961123
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
1124+
let nativeSetTimeout: any = (global as any)[Zone.__symbol__('setTimeout')];
10971125
if (!(global as any).requestAnimationFrame) {
10981126
nativeRequestAnimationFrame = function(fn: Function) {
10991127
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
@@ -1139,6 +1167,20 @@ function commonTests() {
11391167
});
11401168
});
11411169

1170+
it('should only emit onMicroTaskEmpty once within the same event loop for ngZone.run in onMicrotaskEmpty subscription',
1171+
(done: DoneFn) => {
1172+
coalesceZone.onMicrotaskEmpty.subscribe(() => {
1173+
coalesceZone.run(() => {});
1174+
});
1175+
coalesceZone.run(() => {});
1176+
coalesceZone.run(() => {});
1177+
expect(logs).toEqual([]);
1178+
nativeSetTimeout(() => {
1179+
expect(logs).toEqual(['microTask empty']);
1180+
done();
1181+
}, 100);
1182+
});
1183+
11421184
it('should only emit onMicroTaskEmpty once within the same event loop for multiple tasks',
11431185
(done: DoneFn) => {
11441186
const tasks: Task[] = [];

0 commit comments

Comments
 (0)