cancel and onCancel
Cancel a promise from anywhere, stop its work with onCancel, pass a reason, remove listeners and tell a cancellation from a failure.
Two halves of one feature
cancel says “nobody needs this result any more”. onCancel is where the promise reacts to that. A promise
without an onCancel listener is still canceled, but whatever it started keeps running.
Clean up inside the promise
Register the cleanup in the callback, next to the thing that needs it.
import { CancelablePromise } from 'easy-cancelable-promise';
export const openSocket = (url: string) =>
new CancelablePromise<string>((resolve, reject, { onCancel }) => {
const socket = new WebSocket(url);
// the promise owns its cleanup: whoever cancels it does not need to know about the socket
onCancel(() => socket.close());
socket.addEventListener('message', (event) => resolve(String(event.data)), { once: true });
socket.addEventListener('error', () => reject(new Error('Connection failed')), { once: true });
});Whoever cancels the promise does not need to know there is a socket. That is the point: the knowledge of how to stop the work stays with the code that started it.
Listen from outside
onCancel is also a method of the promise. It returns the promise, so listeners can be chained. The listeners
registered inside the callback run first.
import { CancelablePromise } from 'easy-cancelable-promise';
export function listen() {
const calls: string[] = [];
const task = new CancelablePromise<void>((_resolve, _reject, { onCancel }) => {
// listeners of the promise itself run first
onCancel(() => calls.push('inside'));
});
// any number of listeners can be added from outside, and they can be chained
task
.onCancel((reason) => calls.push(`outside: ${String(reason)}`))
.onCancel(() => calls.push('analytics'));
task.cancel('User closed the dialog');
return calls; // ['inside', 'outside: User closed the dialog', 'analytics']
}The reason
cancel accepts a reason. The promise rejects with it, and every listener receives it.
import { CancelablePromise } from 'easy-cancelable-promise';
const forever = () => new CancelablePromise<void>(() => {});
export async function reasons() {
// without a reason the promise rejects with new Error('Promise canceled')
const byDefault = await forever()
.cancel()
.catch((reason) => (reason as Error).message);
// with a reason it rejects with exactly that value
const custom = await forever()
.cancel({ code: 'TIMEOUT' })
.catch((reason) => reason);
return [byDefault, custom]; // ['Promise canceled', { code: 'TIMEOUT' }]
}A cancellation is not a failure
For await and catch a canceled promise is a rejected promise. Read status to know which one it was.
import type { CancelablePromise } from 'easy-cancelable-promise';
export async function load<T>(request: CancelablePromise<T>): Promise<T | null> {
try {
return await request;
} catch (error) {
// a cancellation is not a failure: the status tells them apart
if (request.status === 'canceled') return null;
throw error;
}
}No unhandled rejection
The promise you call cancel on never reports an unhandled rejection, even if nothing catches it. A promise
chained from it is canceled too, but its rejection follows the normal rules: without a handler it is reported.
import { CancelablePromise } from 'easy-cancelable-promise';
const task = new CancelablePromise<void>(() => {});
// no catch needed: canceling a promise never causes an unhandled rejection
task.cancel();
// a promise chained from it is canceled too, but nobody called cancel on that one:
// it rejects like any other promise, so handle the chains you create
task.then(() => console.log('never runs')).catch(() => {});Remove a listener
Inside the callback, onCancel returns the function that removes the listener. Outside, pass a signal.
import { CancelablePromise, CancelableAbortController } from 'easy-cancelable-promise';
export function unsubscribe() {
const calls: string[] = [];
const download = new CancelablePromise<void>((_resolve, _reject, { onCancel }) => {
// inside the promise, onCancel returns the function that removes the listener
const removeFirst = onCancel(() => calls.push('abort the request'));
// the request is over: there is nothing to abort any more
removeFirst();
onCancel(() => calls.push('delete the temporary file'));
});
// outside, a listener is removed with a signal
const controller = new CancelableAbortController();
download.onCancel(() => calls.push('removed before the cancellation'), { signal: controller.signal });
controller.abort();
download.cancel();
return calls; // ['delete the temporary file']
}This is how a promise changes its cleanup as the work advances: abort the request while it downloads, delete the temporary file while it saves. See Abort signals for the controller.
Cancel from inside
The utilities include cancel, so a promise can give up by itself.
import { CancelablePromise } from 'easy-cancelable-promise';
export const withTimeout = (url: string, milliseconds: number) =>
new CancelablePromise<Response>((resolve, reject, { cancel, onCancel }) => {
const controller = new AbortController();
// the promise cancels itself when the request takes too long
const timer = setTimeout(() => cancel('Request timeout'), milliseconds);
onCancel(() => {
clearTimeout(timer);
controller.abort();
});
fetch(url, { signal: controller.signal })
.then(resolve, reject)
.finally(() => clearTimeout(timer));
});When a listener throws
A listener that throws does not stop the cancellation. The other listeners are still called and the promise is
canceled. cancel then throws the first error, so it is not lost.
import { CancelablePromise } from 'easy-cancelable-promise';
export function listenerThrows() {
const calls: string[] = [];
const task = new CancelablePromise<void>(() => {})
.onCancel(() => {
throw new Error('This listener failed');
})
.onCancel(() => calls.push('still called'));
try {
task.cancel();
} catch (error) {
// the first error is thrown by cancel, after the promise is canceled
calls.push((error as Error).message);
}
return [task.status, ...calls]; // ['canceled', 'still called', 'This listener failed']
}Next
- Progress reporting
- Stop the work: the live example of this page.