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