Skip to the code
cf.

Cancellable task with AbortController

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

What the tests check

  1. settles like the task when it isn't cancelled
    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");
    
  2. rejects with the task's error, also when the task throws
    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);
    
  3. calls the task right away with an AbortSignal
    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);
    
  4. cancel() aborts the task's signal
    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);
    
  5. cancel() rejects at once with an AbortError, even if the task ignores the signal
    const { promise, cancel } = cancellable(() => new Promise(() => {}));
    let reason;
    promise.catch((e) => {
      reason = e;
    });
    cancel();
    await flush();
    expect(reason).toBeDefined();
    expect(reason.name).toBe("AbortError");
    
  6. cancel(reason) rejects with that reason
    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);
    
  7. cancel() after the task has settled changes nothing
    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");
    
  8. cancels when the outer signal aborts
    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);
    
  9. doesn't call the task when the outer signal has already aborted
    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);
    

Learn more

solution.jsSaved in this browser

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).