javascriptpromiseabortcontroller

How to elegantly manage AbortSignal event listeners when implementing abortable APIs?


Consider this simple example, probably a function you wrote a couple of times, but now abortable:

/**
 * 
 * @param {number} delay 
 * @param {AbortSignal} [abortSignal]
 * @returns {Promise<void>}
 */
export default function timeoutPromise(delay, abortSignal) {
    return new Promise((resolve, reject) => {
        if(abortSignal) {
            abortSignal.throwIfAborted();
        }

        const timeout = setTimeout(() => {
            resolve();
        }, delay);

        abortSignal.addEventListener("abort", () => {
            clearTimeout(timeout);
            reject(new Error("Aborted"));
        });
    });
}

The obvious issue is that this will not clear the eventListener if the timeout succeeds normally. It can be done, but it is quite ugly:

/**
 * 
 * @param {number} delay 
 * @param {AbortSignal} [abortSignal]
 * @returns {Promise<void>}
 */
export default function timeoutPromise(delay, abortSignal) {
    return new Promise((resolve, reject) => {
        // note: changed to reject() to get consistent behavior regardless of the signal state
        if(abortSignal && abortSignal.aborted) {
            reject(new Error("timeoutPromise aborted"));
        }
        let timeout = null;
        function abortHandler() {
            clearTimeout(timeout);
            reject(new Error("timeoutPromise aborted"))
        }
        timeout = setTimeout(() => {
            if(abortSignal) {
                abortSignal.removeEventListener("abort", abortHandler);
            }
            resolve();
        }, delay);

        if(abortSignal) {
            abortSignal.addEventListener("abort", abortHandler, {once: true});
        }
    });
}

That's... a lot of code for such a simple thing. Am I doing this right or is there a better way?


Solution

  • You can use optional chaining for all the method calls on the AbortSignal and it becomes more straightforward:

    function delay(ms, signal) {
      return new Promise((resolve, reject) => {
        function done() {
          resolve();
          signal?.removeEventListener("abort", stop);
        }
        function stop() {
          reject(this.reason);
          clearTimeout(handle);
        }
        signal?.throwIfAborted();
        const handle = setTimeout(done, ms);
        signal?.addEventListener("abort", stop);
      });
    }
    

    (from my answer to How to cancel JavaScript sleep?)

    Or do only a single check for the signal existence:

    function delay(ms, signal) {
      return new Promise((resolve, reject) => {
        if (!signal) {
          setTimeout(resolve, ms);
          return;
        }
    
        function done() {
          resolve();
          signal.removeEventListener("abort", stop);
        }
        function stop() {
          reject(this.reason);
          clearTimeout(handle);
        }
        signal.throwIfAborted();
        const handle = setTimeout(done, ms);
        signal.addEventListener("abort", stop);
      });
    }
    

    which you can of course golf further:

    function delay(ms, signal) {
      return new Promise((resolve, reject) => {
        if (!signal) return setTimeout(resolve, ms);
        signal.throwIfAborted();
        const handle = setTimeout(() => {
          resolve();
          signal.removeEventListener("abort", stop);
        }, ms);
        const stop = () => {
          reject(signal.reason);
          clearTimeout(handle);
        };
        signal.addEventListener("abort", stop);
      });
    }