diff --git a/lib/common/utils.ts b/lib/common/utils.ts index 36f351f41..5bc698fd5 100644 --- a/lib/common/utils.ts +++ b/lib/common/utils.ts @@ -181,8 +181,18 @@ export interface NestedEventListener { listener?: EventListenerOrEventListenerOb export declare type NestedEventListenerOrEventListenerObject = NestedEventListener | EventListener | EventListenerObject; +export interface EventListenerOptions { capture?: boolean; } + +export interface AddEventListenerOptions extends EventListenerOptions { + passive?: boolean; + once?: boolean; +} + +export declare type EventListenerOptionsOrCapture = + EventListenerOptions | AddEventListenerOptions | boolean; + export interface ListenerTaskMeta extends TaskData { - useCapturing: boolean; + options: EventListenerOptionsOrCapture; eventName: string; handler: NestedEventListenerOrEventListenerObject; target: any; @@ -193,8 +203,23 @@ export interface ListenerTaskMeta extends TaskData { (removeFnSymbol: any, delegate: Task|NestedEventListenerOrEventListenerObject) => any; } +// compare the EventListenerOptionsOrCapture +// 1. if the options is usCapture: boolean, compare the useCpature values directly +// 2. if the options is EventListerOptions, only compare the capture +function compareEventListenerOptions( + left: EventListenerOptionsOrCapture, right: EventListenerOptionsOrCapture): boolean { + const leftCapture: any = (typeof left === 'boolean') ? + left : + ((typeof left === 'object') ? (left && left.capture) : false); + const rightCapture: any = (typeof right === 'boolean') ? + right : + ((typeof right === 'object') ? (right && right.capture) : false); + return !!leftCapture === !!rightCapture; +} + function findExistingRegisteredTask( - target: any, handler: any, name: string, capture: boolean, remove: boolean): Task { + target: any, handler: any, name: string, options: EventListenerOptionsOrCapture, + remove: boolean): Task { const eventTasks: Task[] = target[EVENT_TASKS]; if (eventTasks) { for (let i = 0; i < eventTasks.length; i++) { @@ -202,7 +227,7 @@ function findExistingRegisteredTask( const data = eventTask.data; const listener = data.handler; if ((data.handler === handler || listener.listener === handler) && - data.useCapturing === capture && data.eventName === name) { + compareEventListenerOptions(data.options, options) && data.eventName === name) { if (remove) { eventTasks.splice(i, 1); } @@ -213,15 +238,14 @@ function findExistingRegisteredTask( return null; } -function findAllExistingRegisteredTasks( - target: any, name: string, capture: boolean, remove: boolean): Task[] { +function findAllExistingRegisteredTasks(target: any, name: string, remove: boolean): Task[] { const eventTasks: Task[] = target[EVENT_TASKS]; if (eventTasks) { const result = []; for (let i = eventTasks.length - 1; i >= 0; i--) { const eventTask = eventTasks[i]; const data = eventTask.data; - if (data.eventName === name && data.useCapturing === capture) { + if (data.eventName === name) { result.push(eventTask); if (remove) { eventTasks.splice(i, 1); @@ -247,7 +271,7 @@ function attachRegisteredEvent(target: any, eventTask: Task, isPrepend: boolean) const defaultListenerMetaCreator = (self: any, args: any[]) => { return { - useCapturing: args[2], + options: args[2], eventName: args[0], handler: args[1], target: self || _global, @@ -259,16 +283,15 @@ const defaultListenerMetaCreator = (self: any, args: any[]) => { // remove the delegate directly and try catch error if (!this.crossContext) { if (delegate && (delegate).invoke) { - return this.target[addFnSymbol]( - this.eventName, (delegate).invoke, this.useCapturing); + return this.target[addFnSymbol](this.eventName, (delegate).invoke, this.options); } else { - return this.target[addFnSymbol](this.eventName, delegate, this.useCapturing); + return this.target[addFnSymbol](this.eventName, delegate, this.options); } } else { // add a if/else branch here for performance concern, for most times // cross site context is false, so we don't need to try/catch try { - return this.target[addFnSymbol](this.eventName, delegate, this.useCapturing); + return this.target[addFnSymbol](this.eventName, delegate, this.options); } catch (err) { // do nothing here is fine, because objects in a cross-site context are unusable } @@ -280,16 +303,15 @@ const defaultListenerMetaCreator = (self: any, args: any[]) => { // remove the delegate directly and try catch error if (!this.crossContext) { if (delegate && (delegate).invoke) { - return this.target[removeFnSymbol]( - this.eventName, (delegate).invoke, this.useCapturing); + return this.target[removeFnSymbol](this.eventName, (delegate).invoke, this.options); } else { - return this.target[removeFnSymbol](this.eventName, delegate, this.useCapturing); + return this.target[removeFnSymbol](this.eventName, delegate, this.options); } } else { // add a if/else branch here for performance concern, for most times // cross site context is false, so we don't need to try/catch try { - return this.target[removeFnSymbol](this.eventName, delegate, this.useCapturing); + return this.target[removeFnSymbol](this.eventName, delegate, this.options); } catch (err) { // do nothing here is fine, because objects in a cross-site context are unusable } @@ -314,15 +336,14 @@ export function makeZoneAwareAddListener( function cancelEventListener(eventTask: Task): void { const meta = eventTask.data; - findExistingRegisteredTask( - meta.target, eventTask.invoke, meta.eventName, meta.useCapturing, true); + findExistingRegisteredTask(meta.target, eventTask.invoke, meta.eventName, meta.options, true); return meta.invokeRemoveFunc(removeFnSymbol, eventTask); } return function zoneAwareAddListener(self: any, args: any[]) { const data: ListenerTaskMeta = metaCreator(self, args); - data.useCapturing = data.useCapturing || defaultUseCapturing; + data.options = data.options || defaultUseCapturing; // - Inside a Web Worker, `this` is undefined, the context is `global` // - When `addEventListener` is called on the global context in strict mode, `this` is undefined // see https://github.com/angular/zone.js/issues/190 @@ -351,7 +372,7 @@ export function makeZoneAwareAddListener( if (!allowDuplicates) { const eventTask: Task = findExistingRegisteredTask( - data.target, data.handler, data.eventName, data.useCapturing, false); + data.target, data.handler, data.eventName, data.options, false); if (eventTask) { // we already registered, so this will have noop. return data.invokeAddFunc(addFnSymbol, eventTask); @@ -374,7 +395,7 @@ export function makeZoneAwareRemoveListener( return function zoneAwareRemoveListener(self: any, args: any[]) { const data = metaCreator(self, args); - data.useCapturing = data.useCapturing || defaultUseCapturing; + data.options = data.options || defaultUseCapturing; // - Inside a Web Worker, `this` is undefined, the context is `global` // - When `addEventListener` is called on the global context in strict mode, `this` is undefined // see https://github.com/angular/zone.js/issues/190 @@ -399,8 +420,8 @@ export function makeZoneAwareRemoveListener( if (!delegate || validZoneHandler) { return data.invokeRemoveFunc(symbol, data.handler); } - const eventTask = findExistingRegisteredTask( - data.target, data.handler, data.eventName, data.useCapturing, true); + const eventTask = + findExistingRegisteredTask(data.target, data.handler, data.eventName, data.options, true); if (eventTask) { eventTask.zone.cancelTask(eventTask); } else { @@ -409,9 +430,8 @@ export function makeZoneAwareRemoveListener( }; } -export function makeZoneAwareRemoveAllListeners(fnName: string, useCapturingParam: boolean = true) { +export function makeZoneAwareRemoveAllListeners(fnName: string) { const symbol = zoneSymbol(fnName); - const defaultUseCapturing = useCapturingParam ? false : undefined; return function zoneAwareRemoveAllListener(self: any, args: any[]) { const target = self || _global; @@ -424,13 +444,12 @@ export function makeZoneAwareRemoveAllListeners(fnName: string, useCapturingPara return; } const eventName = args[0]; - const useCapturing = args[1] || defaultUseCapturing; // call this function just remove the related eventTask from target[EVENT_TASKS] - findAllExistingRegisteredTasks(target, eventName, useCapturing, true); // we don't need useCapturing here because useCapturing is just for DOM, and // removeAllListeners should only be called by node eventEmitter // and we don't cancel Task either, because call native eventEmitter.removeAllListeners will // will do remove listener(cancelTask) for us + findAllExistingRegisteredTasks(target, eventName, true); target[symbol](eventName); }; } diff --git a/lib/node/events.ts b/lib/node/events.ts index fcb471ec2..aa4df224f 100644 --- a/lib/node/events.ts +++ b/lib/node/events.ts @@ -30,7 +30,7 @@ const zoneAwarePrependListener = callAndReturnFirstParam( const zoneAwareRemoveListener = callAndReturnFirstParam(makeZoneAwareRemoveListener(EE_REMOVE_LISTENER, false)); const zoneAwareRemoveAllListeners = - callAndReturnFirstParam(makeZoneAwareRemoveAllListeners(EE_REMOVE_ALL_LISTENER, false)); + callAndReturnFirstParam(makeZoneAwareRemoveAllListeners(EE_REMOVE_ALL_LISTENER)); const zoneAwareListeners = makeZoneAwareListeners(EE_LISTENERS); export function patchEventEmitterMethods(obj: any): boolean { diff --git a/test/browser/browser.spec.ts b/test/browser/browser.spec.ts index f430692c8..3f33988e0 100644 --- a/test/browser/browser.spec.ts +++ b/test/browser/browser.spec.ts @@ -33,6 +33,24 @@ function canPatchOnProperty(obj: any, prop: string) { (canPatchOnProperty as any).message = 'patchOnProperties'; +let supportsPassive = false; +try { + const opts = Object.defineProperty({}, 'passive', { + get: function() { + supportsPassive = true; + } + }); + window.addEventListener('test', null, opts); + window.removeEventListener('test', null, opts); +} catch (e) { +} + +function supportEventListenerOptions() { + return supportsPassive; +} + +(supportEventListenerOptions as any).message = 'supportsEventListenerOptions'; + describe('Zone', function() { const rootZone = Zone.current; @@ -237,6 +255,268 @@ describe('Zone', function() { expect(eventListenerSpy).not.toHaveBeenCalled(); }); + it('should support addEventListener/removeEventListener with AddEventListenerOptions with capture setting', + ifEnvSupports(supportEventListenerOptions, function() { + let hookSpy = jasmine.createSpy('hook'); + let cancelSpy = jasmine.createSpy('cancel'); + const logs: string[] = []; + const zone = rootZone.fork({ + name: 'spy', + onScheduleTask: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + task: Task): any => { + hookSpy(); + return parentZoneDelegate.scheduleTask(targetZone, task); + }, + onCancelTask: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + task: Task): any => { + cancelSpy(); + return parentZoneDelegate.cancelTask(targetZone, task); + } + }); + + const docListener = () => { + logs.push('document'); + }; + const btnListener = () => { + logs.push('button'); + }; + + // test capture true + zone.run(function() { + (document as any).addEventListener('click', docListener, {capture: true}); + button.addEventListener('click', btnListener); + }); + + button.dispatchEvent(clickEvent); + expect(hookSpy).toHaveBeenCalled(); + + expect(logs).toEqual(['document', 'button']); + logs.splice(0); + + (document as any).removeEventListener('click', docListener, {capture: true}); + button.removeEventListener('click', btnListener); + expect(cancelSpy).toHaveBeenCalled(); + + button.dispatchEvent(clickEvent); + expect(logs).toEqual([]); + + hookSpy = jasmine.createSpy('hook'); + cancelSpy = jasmine.createSpy('cancel'); + + // test capture false + zone.run(function() { + (document as any).addEventListener('click', docListener, {capture: false}); + button.addEventListener('click', btnListener); + }); + + button.dispatchEvent(clickEvent); + expect(hookSpy).toHaveBeenCalled(); + expect(logs).toEqual(['button', 'document']); + logs.splice(0); + + (document as any).removeEventListener('click', docListener, {capture: false}); + button.removeEventListener('click', btnListener); + expect(cancelSpy).toHaveBeenCalled(); + + button.dispatchEvent(clickEvent); + expect(logs).toEqual([]); + })); + + it('should support mix useCapture with AddEventListenerOptions capture', + ifEnvSupports(supportEventListenerOptions, function() { + let hookSpy = jasmine.createSpy('hook'); + let cancelSpy = jasmine.createSpy('cancel'); + const logs: string[] = []; + const zone = rootZone.fork({ + name: 'spy', + onScheduleTask: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + task: Task): any => { + hookSpy(); + return parentZoneDelegate.scheduleTask(targetZone, task); + }, + onCancelTask: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + task: Task): any => { + cancelSpy(); + return parentZoneDelegate.cancelTask(targetZone, task); + } + }); + + const docListener = () => { + logs.push('document options'); + }; + const docListener1 = () => { + logs.push('document useCapture'); + }; + const btnListener = () => { + logs.push('button'); + }; + + // test capture true + zone.run(function() { + (document as any).addEventListener('click', docListener, {capture: true}); + document.addEventListener('click', docListener1, true); + button.addEventListener('click', btnListener); + }); + + button.dispatchEvent(clickEvent); + expect(hookSpy).toHaveBeenCalled(); + expect(logs).toEqual(['document options', 'document useCapture', 'button']); + logs.splice(0); + + (document as any).removeEventListener('click', docListener, {capture: true}); + document.removeEventListener('click', docListener1, true); + button.removeEventListener('click', btnListener); + expect(cancelSpy).toHaveBeenCalled(); + + button.dispatchEvent(clickEvent); + expect(logs).toEqual([]); + + hookSpy = jasmine.createSpy('hook'); + cancelSpy = jasmine.createSpy('cancel'); + // test removeEventListener by options which was added by useCapture and vice versa + zone.run(function() { + (document as any).addEventListener('click', docListener, {capture: true}); + document.removeEventListener('click', docListener, true); + }); + + button.dispatchEvent(clickEvent); + expect(hookSpy).toHaveBeenCalled(); + expect(cancelSpy).toHaveBeenCalled(); + expect(logs).toEqual([]); + + hookSpy = jasmine.createSpy('hook'); + cancelSpy = jasmine.createSpy('cancel'); + zone.run(function() { + document.addEventListener('click', docListener, true); + (document as any).removeEventListener('click', docListener, {capture: true}); + }); + + button.dispatchEvent(clickEvent); + expect(hookSpy).toHaveBeenCalled(); + expect(cancelSpy).toHaveBeenCalled(); + expect(logs).toEqual([]); + + hookSpy = jasmine.createSpy('hook'); + cancelSpy = jasmine.createSpy('cancel'); + // test removeEventListener by default which was added by options without capture + // property + zone.run(function() { + (document as any).addEventListener('click', docListener, {passive: true}); + (document as any).removeEventListener('click', docListener); + }); + + button.dispatchEvent(clickEvent); + expect(hookSpy).toHaveBeenCalled(); + expect(cancelSpy).toHaveBeenCalled(); + expect(logs).toEqual([]); + + hookSpy = jasmine.createSpy('hook'); + cancelSpy = jasmine.createSpy('cancel'); + // test removeEventListener by default which was added by empty options + zone.run(function() { + (document as any).addEventListener('click', docListener, {}); + (document as any).removeEventListener('click', docListener); + }); + + button.dispatchEvent(clickEvent); + expect(hookSpy).toHaveBeenCalled(); + expect(cancelSpy).toHaveBeenCalled(); + expect(logs).toEqual([]); + })); + + it('should support addEventListener with empty options and treated by capture=false', + ifEnvSupports(supportEventListenerOptions, function() { + let hookSpy = jasmine.createSpy('hook'); + const logs: string[] = []; + const zone = rootZone.fork({ + name: 'spy', + onScheduleTask: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + task: Task): any => { + hookSpy(); + return parentZoneDelegate.scheduleTask(targetZone, task); + } + }); + + const docListener = () => { + logs.push('document options'); + }; + const btnListener = () => { + logs.push('button'); + }; + + zone.run(function() { + (document as any).addEventListener('click', docListener, {}); + button.addEventListener('click', btnListener); + }); + + button.dispatchEvent(clickEvent); + + expect(hookSpy).toHaveBeenCalled(); + hookSpy = jasmine.createSpy('hook'); + expect(logs).toEqual(['button', 'document options']); + + document.removeEventListener('click', docListener); + button.removeEventListener('click', btnListener); + })); + + it('should support addEventListener with AddEventListenerOptions once setting', + ifEnvSupports(supportEventListenerOptions, function() { + let hookSpy = jasmine.createSpy('hook'); + const eventListenerSpy = jasmine.createSpy('eventListener'); + const zone = rootZone.fork({ + name: 'spy', + onScheduleTask: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + task: Task): any => { + hookSpy(); + return parentZoneDelegate.scheduleTask(targetZone, task); + } + }); + + zone.run(function() { + (button as any).addEventListener('click', eventListenerSpy, {once: true}); + }); + + button.dispatchEvent(clickEvent); + + expect(hookSpy).toHaveBeenCalled(); + hookSpy = jasmine.createSpy('hook'); + expect(eventListenerSpy).toHaveBeenCalled(); + + button.dispatchEvent(clickEvent); + expect(hookSpy).not.toHaveBeenCalled(); + })); + + it('should support addEventListener with AddEventListenerOptions passive setting', + ifEnvSupports(supportEventListenerOptions, function() { + const hookSpy = jasmine.createSpy('hook'); + const logs: string[] = []; + const zone = rootZone.fork({ + name: 'spy', + onScheduleTask: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + task: Task): any => { + hookSpy(); + return parentZoneDelegate.scheduleTask(targetZone, task); + } + }); + + const listener = (e: Event) => { + logs.push(e.defaultPrevented.toString()); + e.preventDefault(); + logs.push(e.defaultPrevented.toString()); + }; + + zone.run(function() { + (button as any).addEventListener('click', listener, {passive: true}); + }); + + button.dispatchEvent(clickEvent); + + expect(hookSpy).toHaveBeenCalled(); + expect(logs).toEqual(['false', 'false']); + + button.removeEventListener('click', listener); + })); + it('should support inline event handler attributes', function() { const hookSpy = jasmine.createSpy('hook'); const zone = rootZone.fork({