Cancelable promises have been a topic of debate for a long time since JavaScript first introduced Promises. There hasn’t been a clean way to make a promise cancelable but some have tried.
For example in this article the author explored the idea of allowing an outside signal to abort a timer. They used more promises to accomplish this. However, we now have the AbortController interface which provides a much better way to implement this pattern.
Starting with a non-cancellable example:
function delayedName(delay = 0) { return new Promise((resolve) => { setTimeout(() => resolve('Sukima'), delay); }); } console.log(await delayedName(1000));
Next we need to support the AbortSignal interface.
function delayedName(delay = 0, signal) { let cleanup; if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { let abort = ({ target }) => reject(target.reason); let timer = setTimeout(() => resolve('Sukima'), delay); signal.addEventListener('abort', abort); cleanup = () => { signal.removeEventListener('abort', abort); clearTimeout(timer); }; }).finally(cleanup); } delayedName(1000, AbortSignal.timeout(500)) .then((name) => console.log(name)) .catch((error) => console.log(error));
The pro of this approach is that we leave the construction and management of the signal to the consumer a single signal can be scaled over more than one use.
An alernative might be to generate your own controller. With this you don’t need to worry about removing the handler because the scope is tightly coupled to the invocation. By the time we would need to cleanup the parent scope will be invalid. And the garbage collection will take care of it.
- Note
That sentence reads weird but it made sense in my head at the time.
What that was suposed to mean was if your function creates a resource and that resource becomes marked for disposing it likely doesn't need cleanup (or if it did has an interface for it).
But if your function takes an external resource and subscribes to that resource than the scope of the resource is out of your function’s control. And you need to be sure you have a way to unsubscribe or you risk a possible memory leak.
’Nuff said, here is an alternative interface:
function delayedName(delay = 0) { let timer; let controller = new AbortController(); return { abort: (reason) => controller.abort(reason), promise: new Promise((resolve, reject) => { timer = setTimeout(() => resolve('Sukima'), delay); controller.signal.onabort = ({ target }) => reject(target.reason); }).finally(() => clearTimeout(timer)), }; } let { abort, promise } = delayedName(1000); promise.then( (name) => console.log(name), (error) => console.log(error), ); setTimeout(abort, 500);
The signal pattern here is pretty neat as it offers a lot of flexibility in how it is used while also keeping good encapsulation and separation of concerns.
A controller is responsible for providing a signal instance and a way to set the state of the signal. The signal is only responsible for providing its state and managing event subscriptions. This way the signal instance can be passed around without exposing the ability to mutate its state. While the original controller can still effect that signal’s state ― even from a distance.
The AbortController is tightly coupled to the concept of aborting in this case. Thus, you can make such a pattern yourself in cases where you had a different concept to express.
class Signal() { #controller; constructor(controller) { this.#controller = controller; } get triggered() { return this.#controller.triggered; } subscribe(listener) { if (this.triggered) return; this.#controller.listeners.add(listener); } unsubscribe(listener) { if (this.triggered) return; this.#controller.listeners.delete(listener); } static trigger() { let controller = new SignalController(); controller.trigger(); return controller.signal; } static timeout(delay) { let controller = new SignalController(); setTimeout(() => controller.trigger(), delay); return controller.signal; } static any(signals) { let controller = new SignalController(); let subscription = () => controller.trigger(); for (let signal of signals) signal.subscribe(subscription); return controller.signal; } } class SignalController { #triggered = false; #listeners = new Set(); #signal = new Signal(this); get signal() { return this.#signal; } trigger() { this.#triggered = true; this.#listeners.forEach((i) => i()); this.#listeners.clear(); } } let signal = Signal.timeout(1000); signal.subscribe(() => console.log('triggered'));
If anyone is curious
the HTML spec
suggests a static method any()
which is useful to compose many signals into one.
Unfortunatly,
it seems like this hasn’t been implemented in JS engines yet.
Thus,
here is an implementation to copy-pasta: