From 5e92d649f2e335d39cdd6d5bd580dbaa294a4f00 Mon Sep 17 00:00:00 2001 From: JiaLiPassion Date: Fri, 23 Oct 2020 20:45:51 +0900 Subject: [PATCH] feat(core): add shouldCoalesceRunChangeDetection option to coalesce change detections in the same event loop. (#39422) Close #39348 Now `NgZone` has an option `shouldCoalesceEventChangeDetection` to coalesce multiple event handler's change detections to one async change detection. And there are some cases other than `event handler` have the same issues. In #39348, the case like this. ``` // This code results in one change detection occurring per // ngZone.run() call. This is entirely feasible, and can be a serious // performance issue. for (let i = 0; i < 100; i++) { this.ngZone.run(() => { // do something }); } ``` So such kind of case will trigger multiple change detections. And now with Ivy, we have a new `markDirty()` API will schedule a requestAnimationFrame to trigger change detection and also coalesce the change detections in the same event loop, `markDirty()` API doesn't only take care `event handler` but also all other cases `sync/macroTask/..` So this PR add a new option to coalesce change detections for all cases. test(core): add test case for shouldCoalesceEventChangeDetection option Add new test cases for current `shouldCoalesceEventChangeDetection` in `ng_zone.spec`, since currently we only have integration test for this one. PR Close #39422 --- goldens/public-api/core/core.d.ts | 3 +- .../size-tracking/integration-payloads.json | 14 +- packages/core/src/application_ref.ts | 34 ++- packages/core/src/zone/ng_zone.ts | 72 +++++- packages/core/test/zone/ng_zone_spec.ts | 237 ++++++++++++++++++ .../test/dom/events/event_manager_spec.ts | 150 ++++++++--- 6 files changed, 451 insertions(+), 59 deletions(-) diff --git a/goldens/public-api/core/core.d.ts b/goldens/public-api/core/core.d.ts index 0bbed1ad02..e2a87e69cf 100644 --- a/goldens/public-api/core/core.d.ts +++ b/goldens/public-api/core/core.d.ts @@ -619,9 +619,10 @@ export declare class NgZone { readonly onMicrotaskEmpty: EventEmitter; readonly onStable: EventEmitter; readonly onUnstable: EventEmitter; - constructor({ enableLongStackTrace, shouldCoalesceEventChangeDetection }: { + constructor({ enableLongStackTrace, shouldCoalesceEventChangeDetection, shouldCoalesceRunChangeDetection }: { enableLongStackTrace?: boolean | undefined; shouldCoalesceEventChangeDetection?: boolean | undefined; + shouldCoalesceRunChangeDetection?: boolean | undefined; }); run(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T; runGuarded(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T; diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 7adccac568..292c6782b5 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -3,7 +3,7 @@ "master": { "uncompressed": { "runtime-es2015": 1485, - "main-es2015": 140899, + "main-es2015": 141516, "polyfills-es2015": 36964 } } @@ -39,7 +39,7 @@ "master": { "uncompressed": { "runtime-es2015": 2285, - "main-es2015": 241875, + "main-es2015": 242417, "polyfills-es2015": 36709, "5-es2015": 745 } @@ -48,10 +48,10 @@ "cli-hello-world-lazy-rollup": { "master": { "uncompressed": { - "runtime-es2015": 2285, - "main-es2015": 218340, - "polyfills-es2015": 36709, - "5-es2015": 777 + "runtime-es2015": 2289, + "main-es2015": 218507, + "polyfills-es2015": 36723, + "5-es2015": 781 } } }, @@ -66,4 +66,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/core/src/application_ref.ts b/packages/core/src/application_ref.ts index a088a4ea82..cdff12ceef 100644 --- a/packages/core/src/application_ref.ts +++ b/packages/core/src/application_ref.ts @@ -266,6 +266,25 @@ export interface BootstrapOptions { * the change detection will only be triggered once. */ ngZoneEventCoalescing?: boolean; + + /** + * Optionally specify if `NgZone#run()` method invocations should be coalesced + * into a single change detection. + * + * Consider the following case. + * + * for (let i = 0; i < 10; i ++) { + * ngZone.run(() => { + * // do something + * }); + * } + * + * This case triggers the change detection multiple times. + * With ngZoneRunCoalescing options, all change detections in an event loop trigger only once. + * In addition, the change detection executes in requestAnimation. + * + */ + ngZoneRunCoalescing?: boolean; } /** @@ -316,10 +335,13 @@ export class PlatformRef { // pass that as parent to the NgModuleFactory. const ngZoneOption = options ? options.ngZone : undefined; const ngZoneEventCoalescing = (options && options.ngZoneEventCoalescing) || false; - const ngZone = getNgZone(ngZoneOption, ngZoneEventCoalescing); + const ngZoneRunCoalescing = (options && options.ngZoneRunCoalescing) || false; + const ngZone = getNgZone(ngZoneOption, {ngZoneEventCoalescing, ngZoneRunCoalescing}); const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}]; - // Attention: Don't use ApplicationRef.run here, - // as we want to be sure that all possible constructor calls are inside `ngZone.run`! + // Note: Create ngZoneInjector within ngZone.run so that all of the instantiated services are + // created within the Angular zone + // Do not try to replace ngZone.run with ApplicationRef#run because ApplicationRef would then be + // created outside of the Angular zone. return ngZone.run(() => { const ngZoneInjector = Injector.create( {providers: providers, parent: this.injector, name: moduleFactory.moduleType.name}); @@ -426,7 +448,8 @@ export class PlatformRef { } function getNgZone( - ngZoneOption: NgZone|'zone.js'|'noop'|undefined, ngZoneEventCoalescing: boolean): NgZone { + ngZoneOption: NgZone|'zone.js'|'noop'|undefined, + extra?: {ngZoneEventCoalescing: boolean, ngZoneRunCoalescing: boolean}): NgZone { let ngZone: NgZone; if (ngZoneOption === 'noop') { @@ -434,7 +457,8 @@ function getNgZone( } else { ngZone = (ngZoneOption === 'zone.js' ? undefined : ngZoneOption) || new NgZone({ enableLongStackTrace: isDevMode(), - shouldCoalesceEventChangeDetection: ngZoneEventCoalescing + shouldCoalesceEventChangeDetection: !!extra?.ngZoneEventCoalescing, + shouldCoalesceRunChangeDetection: !!extra?.ngZoneRunCoalescing }); } return ngZone; diff --git a/packages/core/src/zone/ng_zone.ts b/packages/core/src/zone/ng_zone.ts index 04b1420007..7d80492292 100644 --- a/packages/core/src/zone/ng_zone.ts +++ b/packages/core/src/zone/ng_zone.ts @@ -119,7 +119,11 @@ export class NgZone { readonly onError: EventEmitter = new EventEmitter(false); - constructor({enableLongStackTrace = false, shouldCoalesceEventChangeDetection = false}) { + constructor({ + enableLongStackTrace = false, + shouldCoalesceEventChangeDetection = false, + shouldCoalesceRunChangeDetection = false + }) { if (typeof Zone == 'undefined') { throw new Error(`In this configuration Angular requires Zone.js`); } @@ -137,8 +141,11 @@ export class NgZone { if (enableLongStackTrace && (Zone as any)['longStackTraceZoneSpec']) { self._inner = self._inner.fork((Zone as any)['longStackTraceZoneSpec']); } - - self.shouldCoalesceEventChangeDetection = shouldCoalesceEventChangeDetection; + // if shouldCoalesceRunChangeDetection is true, all tasks including event tasks will be + // coalesced, so shouldCoalesceEventChangeDetection option is not necessary and can be skipped. + self.shouldCoalesceEventChangeDetection = + !shouldCoalesceRunChangeDetection && shouldCoalesceEventChangeDetection; + self.shouldCoalesceRunChangeDetection = shouldCoalesceRunChangeDetection; self.lastRequestAnimationFrameId = -1; self.nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame; forkInnerZoneWithAngularBehavior(self); @@ -237,11 +244,49 @@ interface NgZonePrivate extends NgZone { hasPendingMicrotasks: boolean; lastRequestAnimationFrameId: number; isStable: boolean; + /** + * Optionally specify coalescing event change detections or not. + * Consider the following case. + * + *
+ * + *
+ * + * When button is clicked, because of the event bubbling, both + * event handlers will be called and 2 change detections will be + * triggered. We can coalesce such kind of events to trigger + * change detection only once. + * + * By default, this option will be false. So the events will not be + * coalesced and the change detection will be triggered multiple times. + * And if this option be set to true, the change detection will be + * triggered async by scheduling it in an animation frame. So in the case above, + * the change detection will only be trigged once. + */ shouldCoalesceEventChangeDetection: boolean; + /** + * Optionally specify if `NgZone#run()` method invocations should be coalesced + * into a single change detection. + * + * Consider the following case. + * + * for (let i = 0; i < 10; i ++) { + * ngZone.run(() => { + * // do something + * }); + * } + * + * This case triggers the change detection multiple times. + * With ngZoneRunCoalescing options, all change detections in an event loops trigger only once. + * In addition, the change detection executes in requestAnimation. + * + */ + shouldCoalesceRunChangeDetection: boolean; + nativeRequestAnimationFrame: (callback: FrameRequestCallback) => number; - // Cache of "fake" top eventTask. This is done so that we don't need to schedule a new task every - // time we want to run a `checkStable`. + // Cache a "fake" top eventTask so you don't need to schedule a new task every + // time you run a `checkStable`. fakeTopEventTask: Task; } @@ -293,12 +338,9 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { const delayChangeDetectionForEventsDelegate = () => { delayChangeDetectionForEvents(zone); }; - const maybeDelayChangeDetection = !!zone.shouldCoalesceEventChangeDetection && - zone.nativeRequestAnimationFrame && delayChangeDetectionForEventsDelegate; zone._inner = zone._inner.fork({ name: 'angular', - properties: - {'isAngularZone': true, 'maybeDelayChangeDetection': maybeDelayChangeDetection}, + properties: {'isAngularZone': true}, onInvokeTask: (delegate: ZoneDelegate, current: Zone, target: Zone, task: Task, applyThis: any, applyArgs: any): any => { @@ -306,14 +348,14 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { onEnter(zone); return delegate.invokeTask(target, task, applyThis, applyArgs); } finally { - if (maybeDelayChangeDetection && task.type === 'eventTask') { - maybeDelayChangeDetection(); + if ((zone.shouldCoalesceEventChangeDetection && task.type === 'eventTask') || + zone.shouldCoalesceRunChangeDetection) { + delayChangeDetectionForEventsDelegate(); } onLeave(zone); } }, - onInvoke: (delegate: ZoneDelegate, current: Zone, target: Zone, callback: Function, applyThis: any, applyArgs?: any[], source?: string): any => { @@ -321,6 +363,9 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { onEnter(zone); return delegate.invoke(target, callback, applyThis, applyArgs, source); } finally { + if (zone.shouldCoalesceRunChangeDetection) { + delayChangeDetectionForEventsDelegate(); + } onLeave(zone); } }, @@ -351,7 +396,8 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { function updateMicroTaskStatus(zone: NgZonePrivate) { if (zone._hasPendingMicrotasks || - (zone.shouldCoalesceEventChangeDetection && zone.lastRequestAnimationFrameId !== -1)) { + ((zone.shouldCoalesceEventChangeDetection || zone.shouldCoalesceRunChangeDetection) && + zone.lastRequestAnimationFrameId !== -1)) { zone.hasPendingMicrotasks = true; } else { zone.hasPendingMicrotasks = false; diff --git a/packages/core/test/zone/ng_zone_spec.ts b/packages/core/test/zone/ng_zone_spec.ts index fef986e01f..3f11518a0e 100644 --- a/packages/core/test/zone/ng_zone_spec.ts +++ b/packages/core/test/zone/ng_zone_spec.ts @@ -12,6 +12,7 @@ import {AsyncTestCompleter, beforeEach, describe, expect, inject, it, Log, xit} import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; import {scheduleMicroTask} from '../../src/util/microtask'; +import {getNativeRequestAnimationFrame} from '../../src/util/raf'; import {NoopNgZone} from '../../src/zone/ng_zone'; const needsLongerTimers = browserDetection.isSlow || browserDetection.isEdge; @@ -929,4 +930,240 @@ function commonTests() { }); }); }); + + describe('coalescing', () => { + describe( + 'shouldCoalesceRunChangeDetection = false, shouldCoalesceEventChangeDetection = false', + () => { + let notCoalesceZone: NgZone; + let logs: string[] = []; + + beforeEach(() => { + notCoalesceZone = new NgZone({}); + logs = []; + notCoalesceZone.onMicrotaskEmpty.subscribe(() => { + logs.push('microTask empty'); + }); + }); + + it('should run sync', () => { + notCoalesceZone.run(() => {}); + expect(logs).toEqual(['microTask empty']); + }); + + it('should emit onMicroTaskEmpty multiple times within the same event loop for multiple ngZone.run', + () => { + notCoalesceZone.run(() => {}); + notCoalesceZone.run(() => {}); + expect(logs).toEqual(['microTask empty', 'microTask empty']); + }); + + it('should emit onMicroTaskEmpty multiple times within the same event loop for multiple tasks', + () => { + const tasks: Task[] = []; + notCoalesceZone.run(() => { + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask1'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask2'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleMacroTask('myMacro', () => { + logs.push('macroTask'); + }, undefined, () => {})); + }); + tasks.forEach(t => t.invoke()); + expect(logs).toEqual([ + 'microTask empty', 'eventTask1', 'microTask empty', 'eventTask2', + 'microTask empty', 'macroTask', 'microTask empty' + ]); + }); + }); + + describe('shouldCoalesceEventChangeDetection = true, shouldCoalesceRunChangeDetection = false', () => { + let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void; + if (!(global as any).requestAnimationFrame) { + nativeRequestAnimationFrame = function(fn: Function) { + (global as any)[Zone.__symbol__('setTimeout')](fn, 16); + }; + } else { + nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame; + } + let patchedImmediate: any; + let coalesceZone: NgZone; + let logs: string[] = []; + + beforeEach(() => { + patchedImmediate = setImmediate; + (global as any).setImmediate = (global as any)[Zone.__symbol__('setImmediate')]; + coalesceZone = new NgZone({shouldCoalesceEventChangeDetection: true}); + logs = []; + coalesceZone.onMicrotaskEmpty.subscribe(() => { + logs.push('microTask empty'); + }); + }); + + afterEach(() => { + (global as any).setImmediate = patchedImmediate; + }); + + it('should run in requestAnimationFrame async', (done: DoneFn) => { + let task: Task|undefined = undefined; + coalesceZone.run(() => { + task = Zone.current.scheduleEventTask('myEvent', () => { + logs.push('myEvent'); + }, undefined, () => {}); + }); + task!.invoke(); + expect(logs).toEqual(['microTask empty', 'myEvent']); + nativeRequestAnimationFrame(() => { + expect(logs).toEqual(['microTask empty', 'myEvent', 'microTask empty']); + done(); + }); + }); + + it('should only emit onMicroTaskEmpty once within the same event loop for multiple event tasks', + (done: DoneFn) => { + const tasks: Task[] = []; + coalesceZone.run(() => { + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask1'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask2'); + }, undefined, () => {})); + }); + tasks.forEach(t => t.invoke()); + expect(logs).toEqual(['microTask empty', 'eventTask1', 'eventTask2']); + nativeRequestAnimationFrame(() => { + expect(logs).toEqual( + ['microTask empty', 'eventTask1', 'eventTask2', 'microTask empty']); + done(); + }); + }); + + it('should emit onMicroTaskEmpty once within the same event loop for not only event tasks, but event tasks are before other tasks', + (done: DoneFn) => { + const tasks: Task[] = []; + coalesceZone.run(() => { + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask1'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask2'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleMacroTask('myMacro', () => { + logs.push('macroTask'); + }, undefined, () => {})); + }); + tasks.forEach(t => t.invoke()); + expect(logs).toEqual(['microTask empty', 'eventTask1', 'eventTask2', 'macroTask']); + nativeRequestAnimationFrame(() => { + expect(logs).toEqual( + ['microTask empty', 'eventTask1', 'eventTask2', 'macroTask', 'microTask empty']); + done(); + }); + }); + + it('should emit multiple onMicroTaskEmpty within the same event loop for not only event tasks, but event tasks are after other tasks', + (done: DoneFn) => { + const tasks: Task[] = []; + coalesceZone.run(() => { + tasks.push(Zone.current.scheduleMacroTask('myMacro', () => { + logs.push('macroTask'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask1'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask2'); + }, undefined, () => {})); + }); + tasks.forEach(t => t.invoke()); + expect(logs).toEqual( + ['microTask empty', 'macroTask', 'microTask empty', 'eventTask1', 'eventTask2']); + nativeRequestAnimationFrame(() => { + expect(logs).toEqual([ + 'microTask empty', 'macroTask', 'microTask empty', 'eventTask1', 'eventTask2', + 'microTask empty' + ]); + done(); + }); + }); + }); + + describe('shouldCoalesceRunChangeDetection = true', () => { + let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void; + if (!(global as any).requestAnimationFrame) { + nativeRequestAnimationFrame = function(fn: Function) { + (global as any)[Zone.__symbol__('setTimeout')](fn, 16); + }; + } else { + nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame; + } + let patchedImmediate: any; + let coalesceZone: NgZone; + let logs: string[] = []; + + beforeEach(() => { + patchedImmediate = setImmediate; + (global as any).setImmediate = (global as any)[Zone.__symbol__('setImmediate')]; + coalesceZone = new NgZone({shouldCoalesceRunChangeDetection: true}); + logs = []; + coalesceZone.onMicrotaskEmpty.subscribe(() => { + logs.push('microTask empty'); + }); + }); + + afterEach(() => { + (global as any).setImmediate = patchedImmediate; + }); + + it('should run in requestAnimationFrame async', (done: DoneFn) => { + coalesceZone.run(() => {}); + expect(logs).toEqual([]); + nativeRequestAnimationFrame(() => { + expect(logs).toEqual(['microTask empty']); + done(); + }); + }); + + it('should only emit onMicroTaskEmpty once within the same event loop for multiple ngZone.run', + (done: DoneFn) => { + coalesceZone.run(() => {}); + coalesceZone.run(() => {}); + expect(logs).toEqual([]); + nativeRequestAnimationFrame(() => { + expect(logs).toEqual(['microTask empty']); + done(); + }); + }); + + it('should only emit onMicroTaskEmpty once within the same event loop for multiple tasks', + (done: DoneFn) => { + const tasks: Task[] = []; + coalesceZone.run(() => { + tasks.push(Zone.current.scheduleMacroTask('myMacro', () => { + logs.push('macroTask'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask1'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleEventTask('myEvent', () => { + logs.push('eventTask2'); + }, undefined, () => {})); + tasks.push(Zone.current.scheduleMacroTask('myMacro', () => { + logs.push('macroTask'); + }, undefined, () => {})); + }); + tasks.forEach(t => t.invoke()); + expect(logs).toEqual(['macroTask', 'eventTask1', 'eventTask2', 'macroTask']); + nativeRequestAnimationFrame(() => { + expect(logs).toEqual( + ['macroTask', 'eventTask1', 'eventTask2', 'macroTask', 'microTask empty']); + done(); + }); + }); + }); + }); } diff --git a/packages/platform-browser/test/dom/events/event_manager_spec.ts b/packages/platform-browser/test/dom/events/event_manager_spec.ts index c4be06190a..245ca5bf9f 100644 --- a/packages/platform-browser/test/dom/events/event_manager_spec.ts +++ b/packages/platform-browser/test/dom/events/event_manager_spec.ts @@ -335,41 +335,79 @@ describe('EventManager', () => { expect(receivedEvent).toBe(null); }); - it('should only trigger one Change detection when bubbling', (done: DoneFn) => { - doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument(); - zone = new NgZone({shouldCoalesceEventChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); - const element = el('
'); - const child = el('
'); - element.appendChild(child); - doc.body.appendChild(element); - const dispatchedEvent = createMouseEvent('click'); - let receivedEvents: any = []; - let stables: any = []; - const handler = (e: any) => { - receivedEvents.push(e); - }; - const manager = new EventManager([domEventPlugin], zone); - let removerChild: any; - let removerParent: any; + it('should only trigger one Change detection when bubbling with shouldCoalesceEventChangeDetection = true', + (done: DoneFn) => { + doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument(); + zone = new NgZone({shouldCoalesceEventChangeDetection: true}); + domEventPlugin = new DomEventsPlugin(doc); + const element = el('
'); + const child = el('
'); + element.appendChild(child); + doc.body.appendChild(element); + const dispatchedEvent = createMouseEvent('click'); + let receivedEvents: any = []; + let stables: any = []; + const handler = (e: any) => { + receivedEvents.push(e); + }; + const manager = new EventManager([domEventPlugin], zone); + let removerChild: any; + let removerParent: any; - zone.run(() => { - removerChild = manager.addEventListener(child, 'click', handler); - removerParent = manager.addEventListener(element, 'click', handler); - }); - zone.onStable.subscribe((isStable: any) => { - stables.push(isStable); - }); - getDOM().dispatchEvent(child, dispatchedEvent); - requestAnimationFrame(() => { - expect(receivedEvents.length).toBe(2); - expect(stables.length).toBe(1); + zone.run(() => { + removerChild = manager.addEventListener(child, 'click', handler); + removerParent = manager.addEventListener(element, 'click', handler); + }); + zone.onStable.subscribe((isStable: any) => { + stables.push(isStable); + }); + getDOM().dispatchEvent(child, dispatchedEvent); + requestAnimationFrame(() => { + expect(receivedEvents.length).toBe(2); + expect(stables.length).toBe(1); - removerChild && removerChild(); - removerParent && removerParent(); - done(); - }); - }); + removerChild && removerChild(); + removerParent && removerParent(); + done(); + }); + }); + + it('should only trigger one Change detection when bubbling with shouldCoalesceRunChangeDetection = true', + (done: DoneFn) => { + doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument(); + zone = new NgZone({shouldCoalesceRunChangeDetection: true}); + domEventPlugin = new DomEventsPlugin(doc); + const element = el('
'); + const child = el('
'); + element.appendChild(child); + doc.body.appendChild(element); + const dispatchedEvent = createMouseEvent('click'); + let receivedEvents: any = []; + let stables: any = []; + const handler = (e: any) => { + receivedEvents.push(e); + }; + const manager = new EventManager([domEventPlugin], zone); + let removerChild: any; + let removerParent: any; + + zone.run(() => { + removerChild = manager.addEventListener(child, 'click', handler); + removerParent = manager.addEventListener(element, 'click', handler); + }); + zone.onStable.subscribe((isStable: any) => { + stables.push(isStable); + }); + getDOM().dispatchEvent(child, dispatchedEvent); + requestAnimationFrame(() => { + expect(receivedEvents.length).toBe(2); + expect(stables.length).toBe(1); + + removerChild && removerChild(); + removerParent && removerParent(); + done(); + }); + }); it('should not drain micro tasks queue too early with shouldCoalesceEventChangeDetection=true', (done: DoneFn) => { @@ -393,6 +431,52 @@ describe('EventManager', () => { let removerParent: any; let removerChildFocus: any; + zone.run(() => { + removerParent = manager.addEventListener(element, 'click', handler); + removerChildFocus = manager.addEventListener(child, 'blur', blurHandler); + }); + const sub = zone.onStable.subscribe(() => { + logs.push('begin'); + Promise.resolve().then(() => { + logs.push('promise resolved'); + }); + element.appendChild(child); + getDOM().dispatchEvent(child, dispatchedBlurEvent); + sub.unsubscribe(); + logs.push('end'); + }); + getDOM().dispatchEvent(element, dispatchedClickEvent); + requestAnimationFrame(() => { + expect(logs).toEqual(['begin', 'blur', 'end', 'promise resolved']); + + removerParent && removerParent(); + removerChildFocus && removerChildFocus(); + done(); + }); + }); + + it('should not drain micro tasks queue too early with shouldCoalesceRunChangeDetection=true', + (done: DoneFn) => { + doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument(); + zone = new NgZone({shouldCoalesceRunChangeDetection: true}); + domEventPlugin = new DomEventsPlugin(doc); + const element = el('
'); + const child = el('
'); + doc.body.appendChild(element); + const dispatchedClickEvent = createMouseEvent('click'); + const dispatchedBlurEvent: FocusEvent = + getDOM().getDefaultDocument().createEvent('FocusEvent'); + dispatchedBlurEvent.initEvent('blur', true, true); + let logs: any = []; + const handler = () => {}; + + const blurHandler = (e: any) => { + logs.push('blur'); + }; + const manager = new EventManager([domEventPlugin], zone); + let removerParent: any; + let removerChildFocus: any; + zone.run(() => { removerParent = manager.addEventListener(element, 'click', handler); removerChildFocus = manager.addEventListener(child, 'blur', blurHandler);