diff --git a/events.js b/events.js index edd45ce..34b69a0 100644 --- a/events.js +++ b/events.js @@ -448,29 +448,50 @@ function unwrapListeners(arr) { function once(emitter, name) { return new Promise(function (resolve, reject) { - function eventListener() { - if (errorListener !== undefined) { + function errorListener(err) { + emitter.removeListener(name, resolver); + reject(err); + } + + function resolver() { + if (typeof emitter.removeListener === 'function') { emitter.removeListener('error', errorListener); } resolve([].slice.call(arguments)); }; - var errorListener; - - // Adding an error listener is not optional because - // if an error is thrown on an event emitter we cannot - // guarantee that the actual event we are waiting will - // be fired. The result could be a silent way to create - // memory or file descriptor leaks, which is something - // we should avoid. - if (name !== 'error') { - errorListener = function errorListener(err) { - emitter.removeListener(name, eventListener); - reject(err); - }; - emitter.once('error', errorListener); + eventTargetAgnosticAddListener(emitter, name, resolver, { once: true }); + if (name !== 'error') { + addErrorHandlerIfEventEmitter(emitter, errorListener, { once: true }); } - - emitter.once(name, eventListener); }); } + +function addErrorHandlerIfEventEmitter(emitter, handler, flags) { + if (typeof emitter.on === 'function') { + eventTargetAgnosticAddListener(emitter, 'error', handler, flags); + } +} + +function eventTargetAgnosticAddListener(emitter, name, listener, flags) { + if (typeof emitter.on === 'function') { + if (flags.once) { + emitter.once(name, listener); + } else { + emitter.on(name, listener); + } + } else if (typeof emitter.addEventListener === 'function') { + // EventTarget does not have `error` event semantics like Node + // EventEmitters, we do not listen for `error` events here. + emitter.addEventListener(name, function wrapListener(arg) { + // IE does not have builtin `{ once: true }` support so we + // have to do it manually. + if (flags.once) { + emitter.removeEventListener(name, wrapListener); + } + listener(arg); + }); + } else { + throw new TypeError('The "emitter" argument must be of type EventEmitter. Received type ' + typeof emitter); + } +} diff --git a/package.json b/package.json index d936726..9810e97 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "devDependencies": { "airtap": "^1.0.0", "functions-have-names": "^1.2.1", + "has": "^1.0.3", "has-symbols": "^1.0.1", "isarray": "^2.0.5", "tape": "^5.0.0" diff --git a/tests/events-once.js b/tests/events-once.js index 309bf45..dae8649 100644 --- a/tests/events-once.js +++ b/tests/events-once.js @@ -3,8 +3,62 @@ var common = require('./common'); var EventEmitter = require('../').EventEmitter; var once = require('../').once; +var has = require('has'); var assert = require('assert'); +function Event(type) { + this.type = type; +} + +function EventTargetMock() { + this.events = {}; + + this.addEventListener = common.mustCall(this.addEventListener); + this.removeEventListener = common.mustCall(this.removeEventListener); +} + +EventTargetMock.prototype.addEventListener = function addEventListener(name, listener, options) { + if (!(name in this.events)) { + this.events[name] = { listeners: [], options: options || {} } + } + this.events[name].listeners.push(listener); +}; + +EventTargetMock.prototype.removeEventListener = function removeEventListener(name, callback) { + if (!(name in this.events)) { + return; + } + var event = this.events[name]; + var stack = event.listeners; + + for (var i = 0, l = stack.length; i < l; i++) { + if (stack[i] === callback) { + stack.splice(i, 1); + if (stack.length === 0) { + delete this.events[name]; + } + return; + } + } +}; + +EventTargetMock.prototype.dispatchEvent = function dispatchEvent(arg) { + if (!(arg.type in this.events)) { + return true; + } + + var event = this.events[arg.type]; + var stack = event.listeners.slice(); + + for (var i = 0, l = stack.length; i < l; i++) { + stack[i].call(null, arg); + if (event.options.once) { + this.removeEventListener(arg.type, stack[i]); + } + } + return !arg.defaultPrevented; +}; + function onceAnEvent() { var ee = new EventEmitter(); @@ -80,7 +134,9 @@ function onceError() { ee.emit('error', expected); }); - return once(ee, 'error').then(function (args) { + var promise = once(ee, 'error'); + assert.strictEqual(ee.listenerCount('error'), 1); + return promise.then(function (args) { var err = args[0] assert.strictEqual(err, expected); assert.strictEqual(ee.listenerCount('error'), 0); @@ -88,13 +144,91 @@ function onceError() { }); } -Promise.all([ +function onceWithEventTarget() { + var et = new EventTargetMock(); + var event = new Event('myevent'); + process.nextTick(function () { + et.dispatchEvent(event); + }); + return once(et, 'myevent').then(function (args) { + var value = args[0]; + assert.strictEqual(value, event); + assert.strictEqual(has(et.events, 'myevent'), false); + }); +} + +function onceWithEventTargetError() { + var et = new EventTargetMock(); + var error = new Event('error'); + process.nextTick(function () { + et.dispatchEvent(error); + }); + return once(et, 'error').then(function (args) { + var err = args[0]; + assert.strictEqual(err, error); + assert.strictEqual(has(et.events, 'error'), false); + }); +} + +function prioritizesEventEmitter() { + var ee = new EventEmitter(); + ee.addEventListener = assert.fail; + ee.removeAllListeners = assert.fail; + process.nextTick(function () { + ee.emit('foo'); + }); + return once(ee, 'foo'); +} + +var allTests = [ onceAnEvent(), onceAnEventWithTwoArgs(), catchesErrors(), stopListeningAfterCatchingError(), - onceError() -]).catch(function (err) { - console.error(err.stack) - process.exit(1) -}); + onceError(), + onceWithEventTarget(), + onceWithEventTargetError(), + prioritizesEventEmitter() +]; + +var hasBrowserEventTarget = false; +try { + hasBrowserEventTarget = typeof (new window.EventTarget().addEventListener) === 'function' && + new window.Event('xyz').type === 'xyz'; +} catch (err) {} + +if (hasBrowserEventTarget) { + var onceWithBrowserEventTarget = function onceWithBrowserEventTarget() { + var et = new window.EventTarget(); + var event = new window.Event('myevent'); + process.nextTick(function () { + et.dispatchEvent(event); + }); + return once(et, 'myevent').then(function (args) { + var value = args[0]; + assert.strictEqual(value, event); + assert.strictEqual(has(et.events, 'myevent'), false); + }); + } + + var onceWithBrowserEventTargetError = function onceWithBrowserEventTargetError() { + var et = new window.EventTarget(); + var error = new window.Event('error'); + process.nextTick(function () { + et.dispatchEvent(error); + }); + return once(et, 'error').then(function (args) { + var err = args[0]; + assert.strictEqual(err, error); + assert.strictEqual(has(et.events, 'error'), false); + }); + } + + common.test.comment('Testing with browser built-in EventTarget'); + allTests.push([ + onceWithBrowserEventTarget(), + onceWithBrowserEventTargetError() + ]); +} + +module.exports = Promise.all(allTests); diff --git a/tests/index.js b/tests/index.js index 491d0b8..2d739e6 100644 --- a/tests/index.js +++ b/tests/index.js @@ -14,7 +14,15 @@ var require = function(file) { t.on('end', function () { delete common.test; }); common.test = t; - try { orig_require(file); } catch (err) { t.fail(err); } + try { + var exp = orig_require(file); + if (exp && exp.then) { + exp.then(function () { t.end(); }, t.fail); + return; + } + } catch (err) { + t.fail(err); + } t.end(); }); };