Question

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?

 3  66  3
1 Jan 1970

Solution

 4

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);
  });
}
2024-07-15
Bergi