fix(core): NgZone coaleascing options should trigger onStable correctly (#40540)
fix https://github.com/angular/components/issues/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, there is a new flag `zone.isCheckStableRunning` added to prevent re-entry when `shouldCoaleascing` flag is enabled. PR Close #40540
This commit is contained in:
parent
d28197db15
commit
dc9fd1aaef
|
@ -243,6 +243,17 @@ interface NgZonePrivate extends NgZone {
|
||||||
hasPendingMacrotasks: boolean;
|
hasPendingMacrotasks: boolean;
|
||||||
hasPendingMicrotasks: boolean;
|
hasPendingMicrotasks: boolean;
|
||||||
lastRequestAnimationFrameId: number;
|
lastRequestAnimationFrameId: number;
|
||||||
|
/**
|
||||||
|
* A flag to indicate if NgZone is currently inside
|
||||||
|
* checkStable and to prevent re-entry. The flag is
|
||||||
|
* needed because it is possible to invoke the change
|
||||||
|
* detection from within change detection leading to
|
||||||
|
* incorrect behavior.
|
||||||
|
*
|
||||||
|
* For detail, please refer here,
|
||||||
|
* https://github.com/angular/angular/pull/40540
|
||||||
|
*/
|
||||||
|
isCheckStableRunning: boolean;
|
||||||
isStable: boolean;
|
isStable: boolean;
|
||||||
/**
|
/**
|
||||||
* Optionally specify coalescing event change detections or not.
|
* Optionally specify coalescing event change detections or not.
|
||||||
|
@ -291,6 +302,21 @@ interface NgZonePrivate extends NgZone {
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkStable(zone: NgZonePrivate) {
|
function checkStable(zone: NgZonePrivate) {
|
||||||
|
// TODO: @JiaLiPassion, should check zone.isCheckStableRunning to prevent
|
||||||
|
// re-entry. The case is:
|
||||||
|
//
|
||||||
|
// @Component({...})
|
||||||
|
// export class AppComponent {
|
||||||
|
// constructor(private ngZone: NgZone) {
|
||||||
|
// this.ngZone.onStable.subscribe(() => {
|
||||||
|
// this.ngZone.run(() => console.log('stable'););
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// The onStable subscriber run another function inside ngZone
|
||||||
|
// which causes `checkStable()` re-entry.
|
||||||
|
// But this fix causes some issues in g3, so this fix will be
|
||||||
|
// launched in another PR.
|
||||||
if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
|
if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
|
||||||
try {
|
try {
|
||||||
zone._nesting++;
|
zone._nesting++;
|
||||||
|
@ -309,7 +335,20 @@ function checkStable(zone: NgZonePrivate) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function delayChangeDetectionForEvents(zone: NgZonePrivate) {
|
function delayChangeDetectionForEvents(zone: NgZonePrivate) {
|
||||||
if (zone.lastRequestAnimationFrameId !== -1) {
|
/**
|
||||||
|
* We also need to check _nesting here
|
||||||
|
* Consider the following case with shouldCoalesceRunChangeDetection = true
|
||||||
|
*
|
||||||
|
* ngZone.run(() => {});
|
||||||
|
* ngZone.run(() => {});
|
||||||
|
*
|
||||||
|
* We want the two `ngZone.run()` only trigger one change detection
|
||||||
|
* when shouldCoalesceRunChangeDetection is true.
|
||||||
|
* And because in this case, change detection run in async way(requestAnimationFrame),
|
||||||
|
* so we also need to check the _nesting here to prevent multiple
|
||||||
|
* change detections.
|
||||||
|
*/
|
||||||
|
if (zone.isCheckStableRunning || zone.lastRequestAnimationFrameId !== -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
zone.lastRequestAnimationFrameId = zone.nativeRequestAnimationFrame.call(global, () => {
|
zone.lastRequestAnimationFrameId = zone.nativeRequestAnimationFrame.call(global, () => {
|
||||||
|
@ -326,7 +365,9 @@ function delayChangeDetectionForEvents(zone: NgZonePrivate) {
|
||||||
zone.fakeTopEventTask = Zone.root.scheduleEventTask('fakeTopEventTask', () => {
|
zone.fakeTopEventTask = Zone.root.scheduleEventTask('fakeTopEventTask', () => {
|
||||||
zone.lastRequestAnimationFrameId = -1;
|
zone.lastRequestAnimationFrameId = -1;
|
||||||
updateMicroTaskStatus(zone);
|
updateMicroTaskStatus(zone);
|
||||||
|
zone.isCheckStableRunning = true;
|
||||||
checkStable(zone);
|
checkStable(zone);
|
||||||
|
zone.isCheckStableRunning = false;
|
||||||
}, undefined, () => {}, () => {});
|
}, undefined, () => {}, () => {});
|
||||||
}
|
}
|
||||||
zone.fakeTopEventTask.invoke();
|
zone.fakeTopEventTask.invoke();
|
||||||
|
|
|
@ -982,6 +982,7 @@ function commonTests() {
|
||||||
|
|
||||||
describe('shouldCoalesceEventChangeDetection = true, shouldCoalesceRunChangeDetection = false', () => {
|
describe('shouldCoalesceEventChangeDetection = true, shouldCoalesceRunChangeDetection = false', () => {
|
||||||
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
|
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
|
||||||
|
let nativeSetTimeout: any = (global as any)[Zone.__symbol__('setTimeout')];
|
||||||
if (!(global as any).requestAnimationFrame) {
|
if (!(global as any).requestAnimationFrame) {
|
||||||
nativeRequestAnimationFrame = function(fn: Function) {
|
nativeRequestAnimationFrame = function(fn: Function) {
|
||||||
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
|
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
|
||||||
|
@ -1042,6 +1043,32 @@ function commonTests() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should only emit onMicroTaskEmpty once within the same event loop for ngZone.run in onMicrotaskEmpty subscription',
|
||||||
|
(done: DoneFn) => {
|
||||||
|
const tasks: Task[] = [];
|
||||||
|
coalesceZone.onMicrotaskEmpty.subscribe(() => {
|
||||||
|
coalesceZone.run(() => {});
|
||||||
|
});
|
||||||
|
coalesceZone.run(() => {
|
||||||
|
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
|
||||||
|
logs.push('eventTask1');
|
||||||
|
}, undefined, () => {}));
|
||||||
|
});
|
||||||
|
coalesceZone.run(() => {
|
||||||
|
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
|
||||||
|
logs.push('eventTask2');
|
||||||
|
}, undefined, () => {}));
|
||||||
|
});
|
||||||
|
tasks.forEach(t => t.invoke());
|
||||||
|
expect(logs).toEqual(['microTask empty', 'microTask empty', 'eventTask1', 'eventTask2']);
|
||||||
|
nativeSetTimeout(() => {
|
||||||
|
expect(logs).toEqual([
|
||||||
|
'microTask empty', 'microTask empty', 'eventTask1', 'eventTask2', 'microTask empty'
|
||||||
|
]);
|
||||||
|
done();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
it('should emit onMicroTaskEmpty once within the same event loop for not only event tasks, but event tasks are before other tasks',
|
it('should emit onMicroTaskEmpty once within the same event loop for not only event tasks, but event tasks are before other tasks',
|
||||||
(done: DoneFn) => {
|
(done: DoneFn) => {
|
||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
|
@ -1094,6 +1121,7 @@ function commonTests() {
|
||||||
|
|
||||||
describe('shouldCoalesceRunChangeDetection = true', () => {
|
describe('shouldCoalesceRunChangeDetection = true', () => {
|
||||||
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
|
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
|
||||||
|
let nativeSetTimeout: any = (global as any)[Zone.__symbol__('setTimeout')];
|
||||||
if (!(global as any).requestAnimationFrame) {
|
if (!(global as any).requestAnimationFrame) {
|
||||||
nativeRequestAnimationFrame = function(fn: Function) {
|
nativeRequestAnimationFrame = function(fn: Function) {
|
||||||
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
|
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
|
||||||
|
@ -1139,6 +1167,20 @@ function commonTests() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should only emit onMicroTaskEmpty once within the same event loop for ngZone.run in onMicrotaskEmpty subscription',
|
||||||
|
(done: DoneFn) => {
|
||||||
|
coalesceZone.onMicrotaskEmpty.subscribe(() => {
|
||||||
|
coalesceZone.run(() => {});
|
||||||
|
});
|
||||||
|
coalesceZone.run(() => {});
|
||||||
|
coalesceZone.run(() => {});
|
||||||
|
expect(logs).toEqual([]);
|
||||||
|
nativeSetTimeout(() => {
|
||||||
|
expect(logs).toEqual(['microTask empty']);
|
||||||
|
done();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
it('should only emit onMicroTaskEmpty once within the same event loop for multiple tasks',
|
it('should only emit onMicroTaskEmpty once within the same event loop for multiple tasks',
|
||||||
(done: DoneFn) => {
|
(done: DoneFn) => {
|
||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
|
|
|
@ -436,13 +436,13 @@ describe('EventManager', () => {
|
||||||
removerChildFocus = manager.addEventListener(child, 'blur', blurHandler);
|
removerChildFocus = manager.addEventListener(child, 'blur', blurHandler);
|
||||||
});
|
});
|
||||||
const sub = zone.onStable.subscribe(() => {
|
const sub = zone.onStable.subscribe(() => {
|
||||||
|
sub.unsubscribe();
|
||||||
logs.push('begin');
|
logs.push('begin');
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
logs.push('promise resolved');
|
logs.push('promise resolved');
|
||||||
});
|
});
|
||||||
element.appendChild(child);
|
element.appendChild(child);
|
||||||
getDOM().dispatchEvent(child, dispatchedBlurEvent);
|
getDOM().dispatchEvent(child, dispatchedBlurEvent);
|
||||||
sub.unsubscribe();
|
|
||||||
logs.push('end');
|
logs.push('end');
|
||||||
});
|
});
|
||||||
getDOM().dispatchEvent(element, dispatchedClickEvent);
|
getDOM().dispatchEvent(element, dispatchedClickEvent);
|
||||||
|
@ -482,13 +482,13 @@ describe('EventManager', () => {
|
||||||
removerChildFocus = manager.addEventListener(child, 'blur', blurHandler);
|
removerChildFocus = manager.addEventListener(child, 'blur', blurHandler);
|
||||||
});
|
});
|
||||||
const sub = zone.onStable.subscribe(() => {
|
const sub = zone.onStable.subscribe(() => {
|
||||||
|
sub.unsubscribe();
|
||||||
logs.push('begin');
|
logs.push('begin');
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
logs.push('promise resolved');
|
logs.push('promise resolved');
|
||||||
});
|
});
|
||||||
element.appendChild(child);
|
element.appendChild(child);
|
||||||
getDOM().dispatchEvent(child, dispatchedBlurEvent);
|
getDOM().dispatchEvent(child, dispatchedBlurEvent);
|
||||||
sub.unsubscribe();
|
|
||||||
logs.push('end');
|
logs.push('end');
|
||||||
});
|
});
|
||||||
getDOM().dispatchEvent(element, dispatchedClickEvent);
|
getDOM().dispatchEvent(element, dispatchedClickEvent);
|
||||||
|
|
Loading…
Reference in New Issue