Wait for all handlers of a given event to complete

2k Views Asked by At

I am writing a Javascript library and I would like to know if it is possible to do the following.

I want to trigger a custom event on an element, but I don't know a priori which event handler(s) have been subscribed to this event, nor how many. Then, I would like to wait for all these event handlers to complete and then check if any of them has perform a given action (e.g. "reject" the event). If not, then the function that triggers the event shall proceed.

To be clear, I can provide arguments to the event handler(s), such as a "() => reject()" function, or define any sort of "contract" for the event handler, but I cannot modify the code that subscribes the event handler(s). Such code would be written by the users of the library.

Is this possible / desirable?

Thanks!

Update

Here is an example of code snipper I would like to use, consider that library end-user would essentially call addEventListener() or $.on() by themselves

$body = $("body")
function rejectEvent(o) {
    o.reject();
}
function acceptEvent(o) {}
function triggerEvent() {
    let isRejected = false;
    $body.trigger('custom-event', {
        reject: () => isRejected = true;
    });
    // Wait for all event handlers to complete...
    if (isRejected) {
         console.log('stop');
    } else {
         console.log('proceed');
    }
}

triggerEvent(); // Should display 'proceed'
$body.on('custom-event', function(e, o) {
    console.log('do nothing');
});
triggerEvent(); // Should display 'do nothing' then 'proceed'
$body.on('custom-event', function(e, o) {
    console.log('reject');
    o.reject();
});
triggerEvent(); // Should display 'do nothing' then 'reject' then 'stop'
$body.off('custom-event');
$body.on('custom-event', async function(e, o) {
    setTimeout(() => {
        console.log('reject');
        o.reject();
    }, 5000);
});
triggerEvent(); // Should display 'proceed' then 'reject'

As shown by this example, I can correctly retrieve the reject status of the event handlers as long as the event handlers are executed synchronously (at least that what I understood from googling this topic). However, the main issue I have is if the end-user defines the event handlers as asynchronous.

So far, the best option I can see is to document that async event handlers are not supported, but I would love to be able to support them as well.

1

There are 1 best solutions below

2
Peter Seliger On

From the above comments ...

"@PeterSeliger changing the addEventListener method sounds interesting. I guess it could make sense to do that and try to wrap the end-user event handlers (which may be async) within a synchronous function and ensure the user event handlers completes before the wrapper function terminates. Will give it some thoughts, but I welcome more advice." – syl

The half sentence ... "end-user event handlers (which may be async) within a synchronous function" ... makes me wonder whether one should mix classic (fire and forget) event handling with promises and/or async await syntax. Actually until now I never encountered any promised/asynchronous event handler. In case one mixes the latter (asynchronous event handler functions) with dispatching custom events, one either has to replace the prototypal dispatchEvent with an own async function based implementation or one has to come up with an own prototypal extra method for it. – Peter Seliger

And of cause it can be done.

One would start with an intercepting approach for HTMLElement.prototype.addEventListener where one would wrap additional functionality around the original implementation. Thus one later is capable to also control the dispatching of custom events by either replacing HTMLElement.prototype.dispatchEvent with an own implementation or comes up with a custom e.g. HTMLElement.prototype.dispatchMonitoredEvent method or, even better, in case one entirely controls the dispatching environment, one implements it as just to the lib-author accessible dispatchMonitoredEvent method.

As for the interception approach. One does this in order to store element-node specific listeners in a (maybe even globally accessible) listenerStorage which is a WeakMap instance which, based on node references, holds each node's listener Map instance. The latter features event-type specific entries, where one can access the handler-array value by an event-type key.

function handleFormControlEvent({ type: eventType, currentTarget: node }) {
  console.log({
    node,
    nodeValue: node.value,
    eventType,
  });
}
function logFormControlHandlerCount({ type: eventType, currentTarget: node }) {
  console.log({
    [ `${ eventType }HandlerCount` ]: listenerStorage
      .get(node)
      .get(eventType)
      .length
  });
}
function logCheckboxState({ currentTarget: { checked } }) {
  console.log({ checked });
}

document
  .querySelectorAll('input')
  .forEach(elmNode => {
    elmNode.addEventListener('input', handleFormControlEvent);
    elmNode.addEventListener('input', logFormControlHandlerCount);
  });
document
  .querySelectorAll('[type="checkbox"]')
  .forEach(elmNode => {
    elmNode.addEventListener('click', handleFormControlEvent);
    elmNode.addEventListener('click', logCheckboxState);
    elmNode.addEventListener('click', logFormControlHandlerCount);
  });

document
  .querySelector('[type="checkbox"]')
  .dispatchEvent(new CustomEvent('click'));
body { margin: 0; }
fieldset { width: 40%; margin: 0; padding: 0 0 4px 4px; }
.as-console-wrapper { left: auto!important; min-height: 100%!important; width: 58%; }
<fieldset>
  <legend>test area</legend>
  <label>
    <span>Foo:</span>
    <input type="text" value="Foo" />
  </label>
  <label>
    <span>Bar:</span>
    <input type="checkbox" />
  </label>
</fieldset>

<script>
HTMLElement.prototype.addEventListener = (function (proceed) {
  window.listenerStorage = storage = new WeakMap;

  return function addEventListener(type, handler) {
    const currentTarget = this;

    let listeners = storage.get(currentTarget);
    if (!listeners) {
      listeners = new Map;
      storage.set(currentTarget, listeners);
    }
    let handlerList = listeners.get(type);
    if (!handlerList) {
      handlerList = [];
      listeners.set(type, handlerList);
    }
    if (!handlerList.includes(handler)) {
      handlerList.push(handler)
    }
    // proceed with delegation to the original implementation.
    proceed.call(currentTarget, type, handler);
  };
}(HTMLElement.prototype.addEventListener));
</script>

The monitoring depends on either handler functions which have to return a value (which is not the classic handler function way) from which one could tell failure/success or on return values of resolved/settled async handler functions (which is even more unusual). Building on the above example code, the implementation and usage of a non/prototypal async dispatchMonitoredEvent function/method could look like follows ...

function handleFormControlEvent({ type: eventType, currentTarget: node }) {
  console.log({
    node,
    nodeValue: node.value,
    eventType,
  });
  // function statement as event handler, but with unusual return value.
  return { controlEventSuccess: true };
}
function logFormControlHandlerCount({ type: eventType, currentTarget: node }) {
  console.log({
    [ `${ eventType }HandlerCount` ]: listenerStorage
      .get(node)
      .get(eventType)
      .length
  });
  // function statement as event handler, but with unusual return value.
  return { handlerCountSuccess: true };
}
async function logCheckboxState({ currentTarget: { checked } }) {
  // asynchronous function as event handler.
  return new Promise(resolve =>
    setTimeout(() => {

      console.log({ checked });
      resolve({ checkboxStateSuccess: true });

    }, 2000)
  );
}

document
  .querySelectorAll('input')
  .forEach(elmNode => {
    elmNode.addEventListener('input', handleFormControlEvent);
    elmNode.addEventListener('input', logFormControlHandlerCount);
  });
document
  .querySelectorAll('[type="checkbox"]')
  .forEach(elmNode => {
    elmNode.addEventListener('click', handleFormControlEvent);
    elmNode.addEventListener('click', logCheckboxState);
    elmNode.addEventListener('click', logFormControlHandlerCount);
  });

document
  .querySelector('[type="checkbox"]')
  .dispatchEvent(new CustomEvent('click'));

console.log(
  '\n+++ dispatched monitored custom events +++\n\n'
);

(async function () {

  console.log(
    '... trigger ... execute all at once and log at the end ...'
  );
  // execute all at once ...
  Promise
    .all([
      document
        .querySelector('[type="checkbox"]')
        .dispatchMonitoredEvent(new CustomEvent('click')),
      document
        .querySelector('input')
        .dispatchMonitoredEvent(new CustomEvent('input')),
    ])
    // ... and log at the end.
    .then(values =>
      values.forEach(returnValues =>

        console.log({ returnValues })
      )
    );

  console.log(
    '... trigger ... execute and log one after the other ...'
  );
  // execute and log one after the other.
  let returnValues = await document
    .querySelector('[type="checkbox"]')
    .dispatchMonitoredEvent(new CustomEvent('click'));

  console.log({ returnValues });

  returnValues = await document
    .querySelector('input')
    .dispatchMonitoredEvent(new CustomEvent('input'));

  console.log({ returnValues });

})();
body { margin: 0; }
fieldset { width: 40%; margin: 0; padding: 0 0 4px 4px; }
.as-console-wrapper { left: auto!important; min-height: 100%!important; width: 58%; }
<fieldset>
  <legend>test area</legend>
  <label>
    <span>Foo:</span>
    <input type="text" value="Foo" />
  </label>
  <label>
    <span>Bar:</span>
    <input type="checkbox" />
  </label>
</fieldset>

<script>
HTMLElement.prototype.addEventListener = (function (proceed) {
  window.listenerStorage = storage = new WeakMap;

  return function addEventListener(type, handler) {
    const currentTarget = this;

    let listeners = storage.get(currentTarget);
    if (!listeners) {
      listeners = new Map;
      storage.set(currentTarget, listeners);
    }
    let handlerList = listeners.get(type);
    if (!handlerList) {
      handlerList = [];
      listeners.set(type, handlerList);
    }
    if (!handlerList.includes(handler)) {
      handlerList.push(handler)
    }
    // proceed with delegation to the original implementation.
    proceed.call(currentTarget, type, handler);
  };
}(HTMLElement.prototype.addEventListener));

async function dispatchMonitoredEvent({
  isTrusted =false, bubbles = false,
  cancelBubble = false, cancelable = false,
  composed = false, defaultPrevented = false,
  detail = null, eventPhase = 0, path = [],
  returnValue = true, timeStamp = Data.now(),
  type = null,
}) {
  // @TODO ... custom event data handling/copying is in need of improvement.
  const currentTarget = this;
  const monitoredEvent = {
    isTrusted, bubbles, cancelBubble, cancelable, composed, currentTarget,
    defaultPrevented, detail, eventPhase, path, returnValue,
    srcElement: currentTarget, target: currentTarget,
    timeStamp, type,
  };
  const handlerList = listenerStorage
    .get(currentTarget)
    ?.get(type) ?? [];

  return (await Promise
    .all(
      handlerList
        .map(handler =>
          handler(monitoredEvent)
          // (async (evt) => handler(evt))(monitoredEvent)
        )
    ));
};
HTMLElement
  .prototype
  .dispatchMonitoredEvent = dispatchMonitoredEvent;
</script>

Edit

Having learned from the above approach and implementation and taking now the OP's later provided example code into account, one could come up with an implementation for custom cancelable events where the entire async...await handling is build around a custom event's augmented detail property.

Thus, regardless of having registered normal/classic or async handler functions, one does not change a handlers arity (the amount of a function's expected / to be handled arguments) but does control the cancellation of a dispatch process by setting e.g. the sole event-argument's detail.proceed property to false.

All of an element's registered event-type specific handler function's will be processed by an async generator which controls a functions execution and has access to the current event object's reference. Based on either failing/rejected handler functions or on the current event.detail.proceed value, the async generator continues/stops yielding.

The asynchronous dispatchCustomCancelableEvent method which operates the generator does return an object which carries all relevant data, like success, canceled, error and event, about the settled dispatch process. And event.detail provides additional information about the involved handlers and the cancelHandler (the handler which was responsible for any kind of cancelation).

async function triggerTestEvent(elmNode) {
  const result = await elmNode
    .dispatchCustomCancelableEvent('custom-cancelable-event');

  const { /*event, success, */canceled/*, error = null*/ } = result;

  // in order to meet the OP's requirement of ...
  // ... "Wait for all event handlers to complete"
  if (canceled) {
    console.log('stop', { result });
  } else {
    console.log('proceed', { result });
  }
}

(async () => {
  const testNode = document.querySelector('div');

  // should display 'proceed'.
  await triggerTestEvent(testNode); 

  testNode
    .addEventListener('custom-cancelable-event', () =>
      console.log('do nothing')
    );
  // should display 'do nothing' then 'proceed'.
  await triggerTestEvent(testNode);

  testNode
    .addEventListener('custom-cancelable-event', evt => {
      console.log('reject');
      evt.detail.proceed = false; // was ... o.reject();
    });
  testNode
    .addEventListener('custom-cancelable-event', () =>
      console.log('+++ should never be displayed +++')
    );
  // should display 'do nothing' then 'reject' then 'stop'
  await triggerTestEvent(testNode);


  testNode
    .removeCustomListeners('custom-cancelable-event');

  testNode
    .addEventListener('custom-cancelable-event', (/*evt*/) =>
      console.log('... handle event ...')
    );
  testNode
    .addEventListener('custom-cancelable-event', async (evt) =>
      new Promise((resolve/*, reject*/) =>
        setTimeout(() => {

          console.log('... cancle event ...');
          // reject('cancle event');

          evt.detail.proceed = false; // was ... o.reject();
          resolve();

        }, 5000)
      )
    );
  testNode
    .addEventListener('custom-cancelable-event', () =>
      console.log('+++ should never be displayed +++')
    );
  // should display
  // '... handle event ...' then '... cancle event ...' then 'stop'
  await triggerTestEvent(testNode); 

})();
body { margin: 0; }
.as-console-wrapper { left: auto!important; min-height: 100%!important; width: 80%; }
<div>dispatch test node</div>

<script>
HTMLElement.prototype.addEventListener = (function (proceed) {
  window.listenerStorage = storage = new WeakMap;

  return function addEventListener(type, handler) {
    const currentTarget = this;

    let listeners = storage.get(currentTarget);
    if (!listeners) {
      listeners = new Map;
      storage.set(currentTarget, listeners);
    }
    let handlerList = listeners.get(type);
    if (!handlerList) {
      handlerList = [];
      listeners.set(type, handlerList);
    }
    if (!handlerList.includes(handler)) {
      handlerList.push(handler)
    }
    // proceed with delegation to the original implementation.
    proceed.call(currentTarget, type, handler);
  };
}(HTMLElement.prototype.addEventListener));

HTMLElement.prototype.removeCustomListeners =
  function removeCustomListeners (type) {
    storage
      .get(this)
      ?.delete?.(type);
  };

HTMLElement.prototype.dispatchCustomCancelableEvent = (function () {
  async function* createCancelableDispatchablesPool(handlerList, evt) {
    handlerList = [...handlerList];

    let isProceed = true;
    let handler;
    let recentHandler;

    while (isProceed && (handler = handlerList.shift())) {
      try {
        if (evt.detail.proceed === true) {
          recentHandler = handler;

          yield (await handler(evt));
        } else {
          evt.detail.proceed = isProceed = false;
          evt.detail.cancelHandler = recentHandler;
        }
      } catch (reason) {
        // an (async) handler function's execution
        // could also just fail with or without reason.
        evt.detail.proceed = isProceed = false;
        evt.detail.cancelHandler = recentHandler;

        yield (
          new Error(String(reason ?? 'failed without reason'))
        );
      }
    }
  }
  return async function dispatchCustomCancelableEvent(type, options = {}) {
    const currentTarget = this;

    const handlerList = listenerStorage
      .get(currentTarget)
      ?.get(type) ?? [];

    Object.assign((options.detail ??= {}), {

      currentTarget,
      target: currentTarget,

      // extend a custom event's `detail`
      // by a boolean `proceed` property.
      // and a list of all event `handlers`.
      proceed: true,
      handlers: handlerList,
    });
    const customEvent = new CustomEvent(type, options);

    const dispatchablesPool =
      createCancelableDispatchablesPool(handlerList, customEvent);

    const dispatchResult = {
      success: true,
      canceled: true,
    };
    for await (const result of dispatchablesPool) {
      // an (async) handler function's execution
      // could also just fail with or without reason.
      if (result instanceof Error) {
        dispatchResult.success = false;
        dispatchResult.error = result;
      }
    }
    if (customEvent.detail.proceed === true) {

      dispatchResult.canceled = false;

    } else if (!customEvent.detail.cancelHandler) {

      customEvent.detail.cancelHandler =
        // customEvent.detail.handlers.at(-1)
        customEvent.detail.handlers.slice(-1)[0];
    }
    return Object.assign(dispatchResult, { event: customEvent });
  };
}());
</script>