diff --git a/karma-js.conf.js b/karma-js.conf.js index c93da152a5..0a8dde14ee 100644 --- a/karma-js.conf.js +++ b/karma-js.conf.js @@ -35,6 +35,7 @@ module.exports = function(config) { 'node_modules/zone.js/dist/fake-async-test.js', // Including systemjs because it defines `__eval`, which produces correct stack traces. + 'test-events.js', 'shims_for_IE.js', 'node_modules/systemjs/dist/system.src.js', {pattern: 'node_modules/rxjs/**', included: false, watched: false, served: true}, diff --git a/packages/platform-browser/src/dom/events/dom_events.ts b/packages/platform-browser/src/dom/events/dom_events.ts index 77a3d68401..906fb52b2a 100644 --- a/packages/platform-browser/src/dom/events/dom_events.ts +++ b/packages/platform-browser/src/dom/events/dom_events.ts @@ -32,6 +32,20 @@ const ANGULAR = 'ANGULAR'; const NATIVE_ADD_LISTENER = 'addEventListener'; const NATIVE_REMOVE_LISTENER = 'removeEventListener'; +const blackListedEvents: string[] = Zone && Zone[__symbol__('BLACK_LISTED_EVENTS')]; +let blackListedMap: {[eventName: string]: string}; +if (blackListedEvents) { + blackListedMap = {}; + blackListedEvents.forEach(eventName => { blackListedMap[eventName] = eventName; }); +} + +const isBlackListedEvent = function(eventName: string) { + if (!blackListedMap) { + return false; + } + return blackListedMap.hasOwnProperty(eventName); +}; + interface TaskData { zone: any; handler: Function; @@ -49,14 +63,29 @@ const globalListener = function(event: Event) { return; } const args: any = [event]; - taskDatas.forEach(taskData => { + if (taskDatas.length === 1) { + // if taskDatas only have one element, just invoke it + const taskData = taskDatas[0]; if (taskData.zone !== Zone.current) { // only use Zone.run when Zone.current not equals to stored zone return taskData.zone.run(taskData.handler, this, args); } else { return taskData.handler.apply(this, args); } - }); + } else { + // copy tasks as a snapshot to avoid event handlers remove + // itself or others + const copiedTasks = taskDatas.slice(); + for (let i = 0; i < copiedTasks.length; i++) { + const taskData = copiedTasks[i]; + if (taskData.zone !== Zone.current) { + // only use Zone.run when Zone.current not equals to stored zone + taskData.zone.run(taskData.handler, this, args); + } else { + taskData.handler.apply(this, args); + } + } + } }; @Injectable() @@ -86,20 +115,34 @@ export class DomEventsPlugin extends EventManagerPlugin { let callback: EventListener = handler as EventListener; // if zonejs is loaded and current zone is not ngZone // we keep Zone.current on target for later restoration. - if (zoneJsLoaded && !NgZone.isInAngularZone()) { + if (zoneJsLoaded && (!NgZone.isInAngularZone() || isBlackListedEvent(eventName))) { let symbolName = symbolNames[eventName]; if (!symbolName) { symbolName = symbolNames[eventName] = __symbol__(ANGULAR + eventName + FALSE); } let taskDatas: TaskData[] = (element as any)[symbolName]; - const listenerRegistered = taskDatas && taskDatas.length > 0; + const globalListenerRegistered = taskDatas && taskDatas.length > 0; if (!taskDatas) { taskDatas = (element as any)[symbolName] = []; } - if (taskDatas.filter(taskData => taskData.handler === callback).length === 0) { - taskDatas.push({zone: Zone.current, handler: callback}); + + const zone = isBlackListedEvent(eventName) ? Zone.root : Zone.current; + if (taskDatas.length === 0) { + taskDatas.push({zone: zone, handler: callback}); + } else { + let callbackRegistered = false; + for (let i = 0; i < taskDatas.length; i++) { + if (taskDatas[i].handler === callback) { + callbackRegistered = true; + break; + } + } + if (!callbackRegistered) { + taskDatas.push({zone: zone, handler: callback}); + } } - if (!listenerRegistered) { + + if (!globalListenerRegistered) { element[ADD_EVENT_LISTENER](eventName, globalListener, false); } } else { 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 81ca4705ff..480de63bd1 100644 --- a/packages/platform-browser/test/dom/events/event_manager_spec.ts +++ b/packages/platform-browser/test/dom/events/event_manager_spec.ts @@ -116,6 +116,132 @@ export function main() { getDOM().dispatchEvent(element, dispatchedEvent); expect(receivedEvent).toBe(null); }); + + it('should keep zone when addEventListener multiple times', () => { + const Zone = (window as any)['Zone']; + + const element = el('
'); + getDOM().appendChild(doc.body, element); + const dispatchedEvent = getDOM().createMouseEvent('click'); + let receivedEvents: any[] /** TODO #9100 */ = []; + let receivedZones: any[] = []; + const handler1 = (e: any /** TODO #9100 */) => { + receivedEvents.push(e); + receivedZones.push(Zone.current.name); + }; + const handler2 = (e: any /** TODO #9100 */) => { + receivedEvents.push(e); + receivedZones.push(Zone.current.name); + }; + const manager = new EventManager([domEventPlugin], new FakeNgZone()); + + let remover1 = null; + let remover2 = null; + Zone.root.run(() => { remover1 = manager.addEventListener(element, 'click', handler1); }); + Zone.root.fork({name: 'test'}).run(() => { + remover2 = manager.addEventListener(element, 'click', handler2); + }); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvents).toEqual([dispatchedEvent, dispatchedEvent]); + expect(receivedZones).toEqual([Zone.root.name, 'test']); + + receivedEvents = []; + remover1 && remover1(); + remover2 && remover2(); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvents).toEqual([]); + }); + + it('should handle event correctly when one handler remove itself ', () => { + const Zone = (window as any)['Zone']; + + const element = el('
'); + getDOM().appendChild(doc.body, element); + const dispatchedEvent = getDOM().createMouseEvent('click'); + let receivedEvents: any[] /** TODO #9100 */ = []; + let receivedZones: any[] = []; + let remover1: any = null; + let remover2: any = null; + const handler1 = (e: any /** TODO #9100 */) => { + receivedEvents.push(e); + receivedZones.push(Zone.current.name); + remover1 && remover1(); + }; + const handler2 = (e: any /** TODO #9100 */) => { + receivedEvents.push(e); + receivedZones.push(Zone.current.name); + }; + const manager = new EventManager([domEventPlugin], new FakeNgZone()); + + Zone.root.run(() => { remover1 = manager.addEventListener(element, 'click', handler1); }); + Zone.root.fork({name: 'test'}).run(() => { + remover2 = manager.addEventListener(element, 'click', handler2); + }); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvents).toEqual([dispatchedEvent, dispatchedEvent]); + expect(receivedZones).toEqual([Zone.root.name, 'test']); + + receivedEvents = []; + remover1 && remover1(); + remover2 && remover2(); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvents).toEqual([]); + }); + + it('should only add same callback once when addEventListener', () => { + const Zone = (window as any)['Zone']; + + const element = el('
'); + getDOM().appendChild(doc.body, element); + const dispatchedEvent = getDOM().createMouseEvent('click'); + let receivedEvents: any[] /** TODO #9100 */ = []; + let receivedZones: any[] = []; + const handler = (e: any /** TODO #9100 */) => { + receivedEvents.push(e); + receivedZones.push(Zone.current.name); + }; + const manager = new EventManager([domEventPlugin], new FakeNgZone()); + + let remover1 = null; + let remover2 = null; + Zone.root.run(() => { remover1 = manager.addEventListener(element, 'click', handler); }); + Zone.root.fork({name: 'test'}).run(() => { + remover2 = manager.addEventListener(element, 'click', handler); + }); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvents).toEqual([dispatchedEvent]); + expect(receivedZones).toEqual([Zone.root.name]); + + receivedEvents = []; + remover1 && remover1(); + remover2 && remover2(); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvents).toEqual([]); + }); + + it('should run blackListedEvents handler outside of ngZone', () => { + const Zone = (window as any)['Zone']; + const element = el('
'); + getDOM().appendChild(doc.body, element); + const dispatchedEvent = getDOM().createMouseEvent('scroll'); + let receivedEvent: any /** TODO #9100 */ = null; + let receivedZone: any = null; + const handler = (e: any /** TODO #9100 */) => { + receivedEvent = e; + receivedZone = Zone.current; + }; + const manager = new EventManager([domEventPlugin], new FakeNgZone()); + + let remover = manager.addEventListener(element, 'scroll', handler); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvent).toBe(dispatchedEvent); + expect(receivedZone.name).toBe(Zone.root.name); + + receivedEvent = null; + remover && remover(); + getDOM().dispatchEvent(element, dispatchedEvent); + expect(receivedEvent).toBe(null); + }); }); } diff --git a/test-events.js b/test-events.js new file mode 100644 index 0000000000..e32e0a537e --- /dev/null +++ b/test-events.js @@ -0,0 +1,9 @@ +/** + * @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 + */ + +Zone[Zone.__symbol__('BLACK_LISTED_EVENTS')] = ['scroll']; \ No newline at end of file