diff --git a/integration/_payload-limits.json b/integration/_payload-limits.json index cc45acecbbb5e..81aff39d062b2 100644 --- a/integration/_payload-limits.json +++ b/integration/_payload-limits.json @@ -3,7 +3,7 @@ "master": { "uncompressed": { "runtime": 1497, - "main": 166799, + "main": 168478, "polyfills": 43626 } } diff --git a/packages/core/src/application_ref.ts b/packages/core/src/application_ref.ts index e8c6f81545343..d28d02279771b 100644 --- a/packages/core/src/application_ref.ts +++ b/packages/core/src/application_ref.ts @@ -201,6 +201,8 @@ export interface BootstrapOptions { * - `noop` - Use `NoopNgZone` which does nothing. */ ngZone?: NgZone|'zone.js'|'noop'; + + ngZoneEventCoalescing?: boolean; } /** @@ -251,7 +253,8 @@ export class PlatformRef { // So we create a mini parent injector that just contains the new NgZone and // pass that as parent to the NgModuleFactory. const ngZoneOption = options ? options.ngZone : undefined; - const ngZone = getNgZone(ngZoneOption); + const ngZoneEventCoalescing = (options && options.ngZoneEventCoalescing) || false; + const ngZone = getNgZone(ngZoneOption, ngZoneEventCoalescing); 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`! @@ -345,14 +348,17 @@ export class PlatformRef { get destroyed() { return this._destroyed; } } -function getNgZone(ngZoneOption?: NgZone | 'zone.js' | 'noop'): NgZone { +function getNgZone( + ngZoneOption: NgZone | 'zone.js' | 'noop' | undefined, ngZoneEventCoalescing: boolean): NgZone { let ngZone: NgZone; if (ngZoneOption === 'noop') { ngZone = new NoopNgZone(); } else { - ngZone = (ngZoneOption === 'zone.js' ? undefined : ngZoneOption) || - new NgZone({enableLongStackTrace: isDevMode()}); + ngZone = (ngZoneOption === 'zone.js' ? undefined : ngZoneOption) || new NgZone({ + enableLongStackTrace: isDevMode(), + shouldCoalesceEventChangeDetection: ngZoneEventCoalescing + }); } return ngZone; } diff --git a/packages/core/src/util/raf.ts b/packages/core/src/util/raf.ts new file mode 100644 index 0000000000000..9bb3a53c7d7ce --- /dev/null +++ b/packages/core/src/util/raf.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {global} from './global'; + +let nativeRequestAnimationFrame: any; +let nativeCancelAnimationFrame: any; + +function initNativeRequestAnimationFrame() { + nativeRequestAnimationFrame = global['requestAnimationFrame']; + nativeCancelAnimationFrame = global['cancelAnimationFrame']; + if (typeof Zone !== 'undefined' && nativeRequestAnimationFrame && nativeCancelAnimationFrame) { + // use unpatched version of requestAnimationFrame if possible + // to avoid another Change detection + const unpatchedRequestAnimationFrame = + (nativeRequestAnimationFrame as any)[(Zone as any).__symbol__('OriginalDelegate')]; + if (unpatchedRequestAnimationFrame) { + nativeRequestAnimationFrame = unpatchedRequestAnimationFrame; + } + const unpatchedCancelAnimationFrame = + (nativeCancelAnimationFrame as any)[(Zone as any).__symbol__('OriginalDelegate')]; + if (unpatchedCancelAnimationFrame) { + nativeCancelAnimationFrame = unpatchedCancelAnimationFrame; + } + } +} + +initNativeRequestAnimationFrame(); + +export {nativeCancelAnimationFrame, nativeRequestAnimationFrame}; diff --git a/packages/core/src/zone/ng_zone.ts b/packages/core/src/zone/ng_zone.ts index eb79ab04dad45..2d4443fd4105e 100644 --- a/packages/core/src/zone/ng_zone.ts +++ b/packages/core/src/zone/ng_zone.ts @@ -7,6 +7,9 @@ */ import {EventEmitter} from '../event_emitter'; +import {global} from '../util/global'; +import {nativeRequestAnimationFrame} from '../util/raf'; + /** * An injectable service for executing work inside or outside of the Angular zone. @@ -83,8 +86,11 @@ import {EventEmitter} from '../event_emitter'; * @publicApi */ export class NgZone { - readonly hasPendingMicrotasks: boolean = false; + readonly hasPendingZoneMicrotasks: boolean = false; + readonly lastRequestAnimationFrameId: number = -1; + readonly shouldCoalesceEventChangeDetection: boolean = true; readonly hasPendingMacrotasks: boolean = false; + readonly hasPendingMicrotasks: boolean = false; /** * Whether there are no outstanding microtasks or macrotasks. @@ -115,7 +121,8 @@ export class NgZone { */ readonly onError: EventEmitter = new EventEmitter(false); - constructor({enableLongStackTrace = false}) { + + constructor({enableLongStackTrace = false, shouldCoalesceEventChangeDetection = false}) { if (typeof Zone == 'undefined') { throw new Error(`In this configuration Angular requires Zone.js`); } @@ -138,6 +145,7 @@ export class NgZone { self._inner = self._inner.fork((Zone as any)['longStackTraceZoneSpec']); } + self.shouldCoalesceEventChangeDetection = shouldCoalesceEventChangeDetection; forkInnerZoneWithAngularBehavior(self); } @@ -227,10 +235,13 @@ interface NgZonePrivate extends NgZone { _outer: Zone; _inner: Zone; _nesting: number; + _hasPendingMicrotasks: boolean; - hasPendingMicrotasks: boolean; hasPendingMacrotasks: boolean; + hasPendingMicrotasks: boolean; + lastRequestAnimationFrameId: number; isStable: boolean; + shouldCoalesceEventChangeDetection: boolean; } function checkStable(zone: NgZonePrivate) { @@ -251,16 +262,35 @@ function checkStable(zone: NgZonePrivate) { } } +function delayChangeDetectionForEvents(zone: NgZonePrivate) { + if (zone.lastRequestAnimationFrameId !== -1) { + return; + } + zone.lastRequestAnimationFrameId = nativeRequestAnimationFrame.call(global, () => { + zone.lastRequestAnimationFrameId = -1; + updateMicroTaskStatus(zone); + checkStable(zone); + }); + updateMicroTaskStatus(zone); +} + function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { + const delayChangeDetectionForEventsDelegate = () => { delayChangeDetectionForEvents(zone); }; + const maybeDelayChangeDetection = !!zone.shouldCoalesceEventChangeDetection && + nativeRequestAnimationFrame && delayChangeDetectionForEventsDelegate; zone._inner = zone._inner.fork({ name: 'angular', - properties: {'isAngularZone': true}, + properties: + {'isAngularZone': true, 'maybeDelayChangeDetection': maybeDelayChangeDetection}, onInvokeTask: (delegate: ZoneDelegate, current: Zone, target: Zone, task: Task, applyThis: any, applyArgs: any): any => { try { onEnter(zone); return delegate.invokeTask(target, task, applyThis, applyArgs); } finally { + if (maybeDelayChangeDetection && task.type === 'eventTask') { + maybeDelayChangeDetection(); + } onLeave(zone); } }, @@ -283,7 +313,8 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { // We are only interested in hasTask events which originate from our zone // (A child hasTask event is not interesting to us) if (hasTaskState.change == 'microTask') { - zone.hasPendingMicrotasks = hasTaskState.microTask; + zone._hasPendingMicrotasks = hasTaskState.microTask; + updateMicroTaskStatus(zone); checkStable(zone); } else if (hasTaskState.change == 'macroTask') { zone.hasPendingMacrotasks = hasTaskState.macroTask; @@ -299,6 +330,15 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { }); } +function updateMicroTaskStatus(zone: NgZonePrivate) { + if (zone._hasPendingMicrotasks || + (zone.shouldCoalesceEventChangeDetection && zone.lastRequestAnimationFrameId !== -1)) { + zone.hasPendingMicrotasks = true; + } else { + zone.hasPendingMicrotasks = false; + } +} + function onEnter(zone: NgZonePrivate) { zone._nesting++; if (zone.isStable) { @@ -317,6 +357,8 @@ function onLeave(zone: NgZonePrivate) { * to framework to perform rendering. */ export class NoopNgZone implements NgZone { + readonly hasPendingZoneMicrotasks: boolean = false; + readonly lastRequestAnimationFrameId = -1; readonly hasPendingMicrotasks: boolean = false; readonly hasPendingMacrotasks: boolean = false; readonly isStable: boolean = true; @@ -324,6 +366,7 @@ export class NoopNgZone implements NgZone { readonly onMicrotaskEmpty: EventEmitter = new EventEmitter(); readonly onStable: EventEmitter = new EventEmitter(); readonly onError: EventEmitter = new EventEmitter(); + readonly shouldCoalesceEventChangeDetection: boolean = false; run(fn: () => any): any { return fn(); } diff --git a/packages/core/testing/src/ng_zone_mock.ts b/packages/core/testing/src/ng_zone_mock.ts index a3356d8e6b219..cad1508c9bf5a 100644 --- a/packages/core/testing/src/ng_zone_mock.ts +++ b/packages/core/testing/src/ng_zone_mock.ts @@ -16,7 +16,7 @@ import {EventEmitter, Injectable, NgZone} from '@angular/core'; export class MockNgZone extends NgZone { onStable: EventEmitter = new EventEmitter(false); - constructor() { super({enableLongStackTrace: false}); } + constructor() { super({enableLongStackTrace: false, shouldCoalesceEventChangeDetection: false}); } run(fn: Function): any { return fn(); } diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index 6fe51195c9d3a..75a2f167c2cd8 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -390,7 +390,8 @@ export class TestBedViewEngine implements Injector, TestBed { overrideComponentView(component, compFactory); } - const ngZone = new NgZone({enableLongStackTrace: true}); + const ngZone = + new NgZone({enableLongStackTrace: true, shouldCoalesceEventChangeDetection: false}); const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}]; const ngZoneInjector = Injector.create({ providers: providers, 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 893e8c7ba30d4..e8d454285fbe0 100644 --- a/packages/platform-browser/test/dom/events/event_manager_spec.ts +++ b/packages/platform-browser/test/dom/events/event_manager_spec.ts @@ -296,7 +296,7 @@ import {el} from '../../../testing/src/browser_util'; expect(receivedEvents).toEqual([]); }); - it('should run blockListedEvents handler outside of ngZone', () => { + it('should run blackListedEvents handler outside of ngZone', () => { const Zone = (window as any)['Zone']; const element = el('
'); getDOM().appendChild(doc.body, element); @@ -312,13 +312,45 @@ import {el} from '../../../testing/src/browser_util'; let remover = manager.addEventListener(element, 'scroll', handler); getDOM().dispatchEvent(element, dispatchedEvent); expect(receivedEvent).toBe(dispatchedEvent); - expect(receivedZone.name).toBe(Zone.root.name); + expect(receivedZone.name).not.toEqual('angular'); receivedEvent = null; remover && remover(); getDOM().dispatchEvent(element, dispatchedEvent); 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, zone, null); + const element = el('
'); + const child = el('
'); + getDOM().appendChild(element, child); + getDOM().appendChild(doc.body, element); + const dispatchedEvent = getDOM().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(); + }); + }); }); })(); @@ -337,7 +369,7 @@ class FakeEventManagerPlugin extends EventManagerPlugin { } class FakeNgZone extends NgZone { - constructor() { super({enableLongStackTrace: false}); } + constructor() { super({enableLongStackTrace: false, shouldCoalesceEventChangeDetection: true}); } run(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T { return fn(); } runOutsideAngular(fn: Function) { return fn(); } } diff --git a/packages/platform-browser/testing/src/browser_util.ts b/packages/platform-browser/testing/src/browser_util.ts index e260633f7aa34..7420eb3a6ce86 100644 --- a/packages/platform-browser/testing/src/browser_util.ts +++ b/packages/platform-browser/testing/src/browser_util.ts @@ -157,5 +157,5 @@ export function stringifyElement(el: any /** TODO #9100 */): string { } export function createNgZone(): NgZone { - return new NgZone({enableLongStackTrace: true}); + return new NgZone({enableLongStackTrace: true, shouldCoalesceEventChangeDetection: false}); } diff --git a/tools/public_api_guard/core/core.d.ts b/tools/public_api_guard/core/core.d.ts index f07ece41d6275..6b489e7b1aeb6 100644 --- a/tools/public_api_guard/core/core.d.ts +++ b/tools/public_api_guard/core/core.d.ts @@ -607,13 +607,17 @@ export declare class NgProbeToken { export declare class NgZone { readonly hasPendingMacrotasks: boolean; readonly hasPendingMicrotasks: boolean; + readonly hasPendingZoneMicrotasks: boolean; readonly isStable: boolean; + readonly lastRequestAnimationFrameId: number; readonly onError: EventEmitter; readonly onMicrotaskEmpty: EventEmitter; readonly onStable: EventEmitter; readonly onUnstable: EventEmitter; - constructor({ enableLongStackTrace }: { + readonly shouldCoalesceEventChangeDetection: boolean; + constructor({ enableLongStackTrace, shouldCoalesceEventChangeDetection }: { enableLongStackTrace?: boolean | undefined; + shouldCoalesceEventChangeDetection?: boolean | undefined; }); run(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T; runGuarded(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T;