Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

events: support EventTarget in once #29498

Closed
8 changes: 6 additions & 2 deletions doc/api/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,11 +703,15 @@ added: v11.13.0
* `name` {string}
* Returns: {Promise}

Creates a `Promise` that is resolved when the `EventEmitter` emits the given
Creates a `Promise` that is fulfilled when the `EventEmitter` emits the given
event or that is rejected when the `EventEmitter` emits `'error'`.
The `Promise` will resolve with an array of all the arguments emitted to the
given event.

This method is intentionally generic and works with the web platform
[EventTarget](WHATWG-EventTarget) interface, which have no special
`'error'` event semantics and do not listen to the `'error'` event.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I'm missing something, an example showing the use of the EventTarget API would be helpful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const { once } = require('events');
const EventTarget = require('../event-target-implementation')

async function run() {
  const et = new EventTarget();

  process.nextTick(() => {
    et.dispatchEvent('myevent', 42);
  });

  const [value] = await once(et, 'myevent');
  console.log(value);
}

run();

@jasnell something like that?
Not sure about this line const EventTarget = require('../event-target-implementation').

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can defer doing that to a future pull request. Mostly this is for web compatibility and for making EE more universal JavaScript compatible but our docs on the other hand mostly ignore that as far as I know.


```js
const { once, EventEmitter } = require('events');

Expand Down Expand Up @@ -735,7 +739,7 @@ async function run() {

run();
```

[WHATWG-EventTarget](https://dom.spec.whatwg.org/#interface-eventtarget)
[`--trace-warnings`]: cli.html#cli_trace_warnings
[`EventEmitter.defaultMaxListeners`]: #events_eventemitter_defaultmaxlisteners
[`domain`]: domain.html
Expand Down
11 changes: 11 additions & 0 deletions lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,17 @@ function unwrapListeners(arr) {

function once(emitter, name) {
return new Promise((resolve, reject) => {
if (typeof emitter.addEventListener === 'function') {
// EventTarget does not have `error` event semantics like Node
// EventEmitters, we do not listen to `error` events here.
emitter.addEventListener(
mcollina marked this conversation as resolved.
Show resolved Hide resolved
name,
(...args) => { resolve(args); },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EventTarget is guaranteed to only have one argument, so you could avoid passing an array here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@domenic what do you think is the right call API wise in terms of compatibility with EventEmitter and the coming (?) events proposal?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't pay much attention to early-stage TC39 proposals. But I think just compatibility with how events work in event listeners in the DOM would suggest using a single argument instead of an array containing that argument.

Copy link
Contributor

@ronkorving ronkorving Sep 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this now resolves with an array, and just got released in Node v12.11.0. Are we now stuck with this promise signature?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this now resolves with an array, and just got released in Node v12.11.0. Are we now stuck with this promise signature?

I think changing that would be a breaking change, yes.

That being said, I think there is some value in keeping this consistent between EventTarget and EventEmitter, so I’m not really sure that we should change this even if we could.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about consistency with web EventTarget? If that is a goal, arrays should not get involved.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@domenic @addaleax I thought about return one argument, but for consistency with EventEmitter decide to keep the array.

I will open soon a new PR with some improvements.
We can go with EventTarget API and I will update the code, docs and the tests.

WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about consistency with web EventTarget? If that is a goal, arrays should not get involved.

@domenic Yes, there’s a tradeoff, but at least to me a polymorphic API seems worse here than one that performs some unnecessary boxing.

That being said: I personally don’t like the decision to use arrays in the first place, but I think we’re stuck with that now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I'm looking at this and I'm pretty sure Domenic was right and our implementation is wrong. Even for consistency.

Our tests incorrectly use an EventTargetMock that accepts multiple arguments (which isn't possible) and dispatches strings (and not objects).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #33659

{ once: true }
);
return;
}

const eventListener = (...args) => {
if (errorListener !== undefined) {
emitter.removeListener('error', errorListener);
Expand Down
87 changes: 86 additions & 1 deletion test/parallel/test-events-once.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,53 @@ const common = require('../common');
const { once, EventEmitter } = require('events');
const { strictEqual, deepStrictEqual } = require('assert');

class EventTargetMock {
constructor() {
this.events = {};
}

addEventListener = common.mustCall(function(name, listener, options) {
if (!(name in this.events)) {
this.events[name] = { listeners: [], options };
}
this.events[name].listeners.push(listener);
});

removeEventListener = common.mustCall(function(name, callback) {
if (!(name in this.events)) {
return;
}
const event = this.events[name];
const stack = event.listeners;

for (let i = 0, l = stack.length; i < l; i++) {
if (stack[i] === callback) {
stack.splice(i, 1);
if (stack.length === 0) {
Reflect.deleteProperty(this.events, name);
}
return;
}
}
});

dispatchEvent = function(name, ...arg) {
if (!(name in this.events)) {
return true;
}
const event = this.events[name];
const stack = event.listeners.slice();

for (let i = 0, l = stack.length; i < l; i++) {
stack[i].apply(this, arg);
if (event.options.once) {
this.removeEventListener(name, stack[i]);
}
}
return !name.defaultPrevented;
};
}

async function onceAnEvent() {
const ee = new EventEmitter();

Expand Down Expand Up @@ -84,10 +131,48 @@ async function onceError() {
strictEqual(ee.listenerCount('myevent'), 0);
}

async function onceWithEventTarget() {
const et = new EventTargetMock();

process.nextTick(() => {
et.dispatchEvent('myevent', 42);
});
const [ value ] = await once(et, 'myevent');
strictEqual(value, 42);
strictEqual(Reflect.has(et.events, 'myevent'), false);
}

async function onceWithEventTargetTwoArgs() {
const et = new EventTargetMock();

process.nextTick(() => {
et.dispatchEvent('myevent', 42, 24);
});

const value = await once(et, 'myevent');
deepStrictEqual(value, [42, 24]);
}

async function onceWithEventTargetError() {
const et = new EventTargetMock();

const expected = new Error('kaboom');
process.nextTick(() => {
et.dispatchEvent('error', expected);
});

const [err] = await once(et, 'error');
strictEqual(err, expected);
strictEqual(Reflect.has(et.events, 'error'), false);
}

Promise.all([
onceAnEvent(),
onceAnEventWithTwoArgs(),
catchesErrors(),
stopListeningAfterCatchingError(),
onceError()
onceError(),
onceWithEventTarget(),
onceWithEventTargetTwoArgs(),
onceWithEventTargetError(),
]).then(common.mustCall());