Skip to the code
cf.

Turn events into an async iterator

Write on(target, type). target is anything with addEventListener and removeEventListener: a DOM element, window, or new EventTarget(). on returns an async iterator over the target's events of type type. It's also async iterable (its [Symbol.asyncIterator]() returns itself), so you can write for await (const event of on(button, "click")).

on starts listening as soon as it's called: it calls target.addEventListener(type, listener) once, with a listener function of its own. So events that arrive before the first next() aren't missed.

next() returns a promise of { value: event, done: false }. Events that arrive while no next() is waiting are kept in a buffer, and next() takes the oldest one. When the buffer is empty, next() waits for the next event. Several next() calls can wait at once; they get events in the order they were called.

return() ends the iterator. It drops any buffered events, resolves every waiting next() with { value: undefined, done: true }, and returns a promise of the same. After that, next() resolves with { value: undefined, done: true } straight away. Breaking out of a for await loop calls return().

return() also stops listening: it calls target.removeEventListener(type, listener) once, with the same type and the very same listener function that on passed to addEventListener. removeEventListener only removes a listener when it's given that exact function, so passing a new function, even one with the same code, removes nothing.

const target = new EventTarget();
const pings = on(target, "ping");
 
const early = new Event("ping");
target.dispatchEvent(early); // nobody is waiting: buffered
await pings.next();          // { value: early, done: false }, at once
 
const waiting = pings.next(); // the buffer is empty: waits
const later = new Event("ping");
target.dispatchEvent(later);
await waiting; // { value: later, done: false }
 
await pings.return(); // { value: undefined, done: true }; the listener is removed
await pings.next();   // { value: undefined, done: true }

What the tests check

  1. next() waits for the next event and resolves with it
    const target = new EventTarget();
    const pings = on(target, "ping");
    let result;
    pings.next().then((value) => {
      result = value;
    });
    await flush();
    expect(result).toBeUndefined();
    const event = new Event("ping");
    target.dispatchEvent(event);
    await flush();
    expect(result).toEqual({ value: event, done: false });
    
  2. buffers events nobody is waiting for, oldest first
    const target = new EventTarget();
    const pings = on(target, "ping");
    const first = new Event("ping");
    const second = new Event("ping");
    target.dispatchEvent(first);
    target.dispatchEvent(new Event("pong"));
    target.dispatchEvent(second);
    const results = [];
    pings.next().then((result) => results.push(result));
    pings.next().then((result) => results.push(result));
    await flush();
    expect(results).toEqual([
      { value: first, done: false },
      { value: second, done: false },
    ]);
    
  3. waiting next() calls get events in the order they were called
    const target = new EventTarget();
    const pings = on(target, "ping");
    const results = [];
    pings.next().then((result) => results.push(["first call", result.value]));
    pings.next().then((result) => results.push(["second call", result.value]));
    const a = new Event("ping");
    const b = new Event("ping");
    target.dispatchEvent(a);
    target.dispatchEvent(b);
    await flush();
    expect(results).toEqual([
      ["first call", a],
      ["second call", b],
    ]);
    
  4. adds one listener for the type as soon as on() is called
    const target = { addEventListener: spy(), removeEventListener: spy() };
    on(target, "ping");
    expect(target.addEventListener).toHaveBeenCalledTimes(1);
    expect(target.addEventListener.calls[0].args[0]).toBe("ping");
    expect(target.removeEventListener).not.toHaveBeenCalled();
    
  5. return() removes the same listener it added, and resolves as done
    const target = { addEventListener: spy(), removeEventListener: spy() };
    const pings = on(target, "ping");
    expect(target.addEventListener).toHaveBeenCalledTimes(1);
    const added = target.addEventListener.calls[0].args[1];
    let returned;
    pings.return().then((result) => {
      returned = result;
    });
    await flush();
    expect(returned).toEqual({ value: undefined, done: true });
    expect(target.removeEventListener).toHaveBeenCalledTimes(1);
    const [type, removed] = target.removeEventListener.calls[0].args;
    expect(type).toBe("ping");
    // removeEventListener removes nothing unless it gets the very function addEventListener got.
    expect({ sameListenerAsAdded: removed === added }).toEqual({ sameListenerAsAdded: true });
    
  6. return() ends every waiting next() call
    const target = new EventTarget();
    const pings = on(target, "ping");
    const results = [];
    pings.next().then((result) => results.push(result));
    pings.next().then((result) => results.push(result));
    pings.return();
    await flush();
    expect(results).toEqual([
      { value: undefined, done: true },
      { value: undefined, done: true },
    ]);
    
  7. after return(), next() is done at once, whatever was dispatched
    const target = new EventTarget();
    const pings = on(target, "ping");
    target.dispatchEvent(new Event("ping"));
    pings.return();
    target.dispatchEvent(new Event("ping"));
    const results = [];
    pings.next().then((result) => results.push(result));
    pings.next().then((result) => results.push(result));
    await flush();
    expect(results).toEqual([
      { value: undefined, done: true },
      { value: undefined, done: true },
    ]);
    
  8. works with for await...of, and break removes the listener
    const target = new EventTarget();
    const removeEventListener = spy((type, listener) => EventTarget.prototype.removeEventListener.call(target, type, listener));
    target.removeEventListener = removeEventListener;
    const pings = on(target, "ping");
    expect(pings[Symbol.asyncIterator]()).toBe(pings);
    const seen = [];
    let failure;
    (async () => {
      for await (const event of pings) {
        seen.push(event);
        if (seen.length === 2) break;
      }
    })().catch((error) => {
      failure = error;
    });
    const a = new Event("ping");
    const b = new Event("ping");
    target.dispatchEvent(a);
    target.dispatchEvent(b);
    target.dispatchEvent(new Event("ping"));
    await flush();
    expect(failure).toBeUndefined();
    expect(seen).toEqual([a, b]);
    expect(removeEventListener).toHaveBeenCalledTimes(1);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).