Cancelling async work means telling something you started, such as a request, that you no longer need its result, so it can stop. You want that whenever the result has become useless: the user typed a new search, closed the dialog, or left the page. Stopping saves bandwidth, battery, and server time, and keeps an out-of-date result from turning up later.
A promise can't be stopped from outside. It's only a placeholder for a result: it has no cancel method, and the work behind it carries on whether or not anyone is still waiting for it. So cancelling is a request, not a command. You give the work a way to hear that it should stop, and the work stops itself. Work that never listens carries on regardless.
The standard way to ask is AbortController, built into browsers and Node.js. Each controller comes with a signal, an AbortSignal. You keep the controller and hand the signal to the work; calling controller.abort() then tells everything holding the signal to stop. fetch takes a signal as an option, and aborting it cancels the request:
const controller = new AbortController();
fetch("/api/search?q=shoes", { signal: controller.signal })
.then((response) => response.json())
.then((results) => console.log(results)) // never runs
.catch((error) => console.log(error.name)); // "AbortError"
controller.abort(); // the user typed something else
console.log(controller.signal.aborted); // true
When a signal is aborted, three things happen: signal.aborted turns true, signal.reason holds the reason passed to abort(reason), and the signal fires an abort event, so work that's waiting can react straight away. A signal is aborted only once: calling abort again does nothing.
const controller = new AbortController();
const { signal } = controller;
signal.addEventListener("abort", () => console.log("abort event:", signal.reason));
signal.aborted; // false
controller.abort("The dialog was closed"); // logs "abort event: The dialog was closed"
signal.aborted; // true
signal.reason; // "The dialog was closed"
controller.abort("Again"); // does nothing: no second event, and the reason stays the same
By convention, cancelled work rejects with an error whose name is "AbortError". Calling abort() without a reason sets signal.reason to a DOMException with that name (DOMException is the error type web APIs use), and a fetch aborted that way rejects with an AbortError too. Callers check error.name === "AbortError" to tell a cancellation, which they asked for and usually ignore, from a real failure.
Signals turn up across the platform. addEventListener takes a signal option, and aborting that signal removes the listener. AbortSignal.timeout(ms) returns a signal that aborts by itself after ms milliseconds, with a DOMException named "TimeoutError" as its reason.
Write cancellable(task, options). task is an async function that takes an AbortSignal, the way fetch does. cancellable creates an AbortController, calls task(signal) right away with its signal, and returns { promise, cancel }.
promise settles like the task: it fulfills with the task's result, or rejects with its error, also when task throws.
cancel(reason) aborts the signal with reason and rejects promise at once with signal.reason, even if the task ignores the signal. Without a reason, signal.reason is the platform's abort error, whose name is "AbortError". Once promise has settled, cancel does nothing: the signal stays unaborted.
options.signal is an optional outer signal. When it aborts, cancel with its reason. If it has already aborted, don't call task at all, and reject promise with its reason.
const search = cancellable((signal) => fetch("/api/search?q=shoes", { signal }));
search.cancel(); // the fetch is aborted, and search.promise rejects with an AbortError
// Tie a task to something bigger, such as the page it belongs to:
const page = new AbortController();
const load = cancellable((signal) => loadDashboard(signal), { signal: page.signal });
page.abort(); // cancels load too
const clock = useFakeTimers();
const { promise } = cancellable(() => new Promise((resolve) => setTimeout(resolve, 100, "done")));
let result;
promise.then((value) => {
result = value;
});
await clock.tickAsync(100);
expect(result).toBe("done");
const error = new Error("offline");
const rejected = cancellable(async () => {
throw error;
});
const thrown = cancellable(() => {
throw error;
});
await expect(rejected.promise).rejects.toBe(error);
await expect(thrown.promise).rejects.toBe(error);
const task = spy(() => new Promise(() => {}));
cancellable(task);
expect(task).toHaveBeenCalledTimes(1);
const signal = task.calls[0].args[0];
expect(signal).toBeInstanceOf(AbortSignal);
expect(signal.aborted).toBe(false);
const task = spy(() => new Promise(() => {}));
const { promise, cancel } = cancellable(task);
promise.catch(() => {}); // mark it handled, so it's never reported as unhandled
cancel();
expect(task.calls[0].args[0].aborted).toBe(true);
const { promise, cancel } = cancellable(() => new Promise(() => {}));
let reason;
promise.catch((e) => {
reason = e;
});
cancel();
await flush();
expect(reason).toBeDefined();
expect(reason.name).toBe("AbortError");
const task = spy(() => new Promise(() => {}));
const { promise, cancel } = cancellable(task);
const reason = new Error("user left the page");
let caught;
promise.catch((e) => {
caught = e;
});
cancel(reason);
await flush();
expect(caught).toBe(reason);
expect(task.calls[0].args[0].reason).toBe(reason);
const task = spy(async () => "done");
const { promise, cancel } = cancellable(task);
await promise;
cancel();
expect(task.calls[0].args[0].aborted).toBe(false);
await expect(promise).resolves.toBe("done");
const outer = new AbortController();
const task = spy(() => new Promise(() => {}));
const { promise } = cancellable(task, { signal: outer.signal });
let caught;
promise.catch((e) => {
caught = e;
});
const reason = new Error("navigated away");
outer.abort(reason);
await flush();
expect(caught).toBe(reason);
expect(task.calls[0].args[0].aborted).toBe(true);
const outer = new AbortController();
const reason = new Error("too late");
outer.abort(reason);
const task = spy(() => "ran");
const { promise } = cancellable(task, { signal: outer.signal });
let caught;
promise.catch((e) => {
caught = e;
});
await flush();
expect(task).not.toHaveBeenCalled();
expect(caught).toBe(reason);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Cancellable task with AbortController. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.