TL;DR: An idea for a small library that makes it easier to abort Promise chains.

A chain
A chain. Photo by Joey Kyber. Via Pexels.

But... why?

I've found myself wanting to be able to easily abort a Promise chain many times. And by that I don't mean just the initial promise itself (which is possible), I want the ability to abort anywhere in a Promise, even in the middle of the chain.

"But why?", you ask.

Take the following example, where the drawChatroomAndMessages() method needs both the Chatroom and ChatMessages data to run:

Promise.resolve()
.then( () => loadChatroom() )
.then( () => loadChatMessages() )
.then( () => drawChatroomAndMessages() );

What if the that Promise chain is running, but the user clicks to open another chatroom while it's still loading? Depending on how the site is built, I now may have a race-condition on my hands 😥.

I'd like a way to abort the Promise chain whenever I start a new link in that chain:

Promise.resolve()
.then( () => {
  throwIfAborted();
  // Do stuff..
})
.then( () => {
  throwIfAborted();
  // Do stuff..
})
.then( () => {
  throwIfAborted();
  // Etcetera
})

Code snippet(s)!

So I've come up with the following mini library thingie. The idea is as follows:

  1. Create an AbortController and a unique token (it's possible to use an existing AbortController and/or token).
  2. Run the promise chain and, in each .then(), check if the signal was aborted (and automatically throw an AbortError if it was).
  3. If the chain completes, clean up the controller and token.
  4. Before running 1, make sure to call abort() on the controller that was created for the previous (= possibly an ongoing, not-yet completed) run of the chain.

The code snippet below is very much a draft. It probably needs more testing, refinement, etcetera, but I thought I'd share the idea early and maybe get some feedback 👍
Mastodon: @rgadellaa@mastodon.social
Twitter: @rgadellaa

The library

import { unique } from './val';

/** @private */
const _tokens = new Map();

/**
 * Register a new (or existing) AbortController
 * and generate a new (or use a pre-defined)
 * token.
 */
export function tokenize({
  controller = new AbortController(),
  token = unique()
}={}) {

  const { signal } = controller;

  // Check if the token exists. In that case, 
  // return existing token and controller
  if ( _tokens.has( token ) )
    return { 
      controller: _tokens.get( token ),
      token, 
    };

  // Store the token and AbortController
  _tokens.set( token, controller );

  // Listen for abort event (and clean up if
  // it fires)
  signal.addEventListener( 'abort', () => {
    cleanup( token );
  }, { once: true });

  // Return the token and controller
  return { token, controller };

}

/**
 * Signal abort for the given token.
 */
export function abort( token ) {
  _tokens.has( token )
    && _tokens.get( token ).abort();
}

/**
 * Check if the AbortSignal for the given token
 * is aborted.
 */
export function isAborted( token ) {
  return !_tokens.has( token )
    || _tokens.get( token ).signal.aborted === true;
}

/**
 * Check if the AbortSignal for the given token
 * is aborted *and* throw an AbortError if it
 * is.
 */
export function throwIfAborted( token ) {
  if (isAborted( token ))
    throw new DOMException('Aborted', 'AbortError');
}

/**
 * Remove the token and AbortController.
 */
export function cleanup( token ) {
  if (_tokens.has( token))
    _tokens.delete( token );
}

A promise chain

Let's use the code above and define a function that does some async work but will abort the work if it's called again:

import { wait } from './promise';

let _token;

/**
 * Do some async work.
 */
async function doSomeAsyncWork( arg ) {

  // Abort any ongoing work that may still be
  // in progress
  abort( _token );

  // Create an AbortController, token, and
  // store the token.
  const { token } = tokenize();
  _token = token;

  // Run a promise and call `throwIfAborted()`
  // whenever we might want to abort the promise
  // chain. In the `finally()`, we clean up the
  // controller and generated token.
  Promise.resolve()
  .then( async () => {
    throwIfAborted( token );
    console.log('Then #1', arg);
    await wait( 500 );
  })
  .then( async () => {
    throwIfAborted( token );
    console.log('Then #2', arg);
    await wait( 500 );
  })
  .catch( error => {
    if (error.name === 'AbortError') {
      console.warn('Aborted', arg);
      return;
    }
    throw error;
  })
  .finally( () => {
    cleanup( token );
  })
  ;

}

In action

Now, let's do some async work - and then drop what we're doing in the middle of the chain and do some other work! 💪

(async () => {

  // Start some work for 'A', wait 250ms, then
  // realize your error and start work for 'B'
  // instead.
  // Once 'B' is started, 'A' will abort as
  // soon as it hits a `throwIfAborted()` call.
  doSomeAsyncWork('A');
  await wait( 250 );
  doSomeAsyncWork('B');

})();

This will log:

Then #1 A
Then #1 B
Aborted A <--- Success!
Then #2 B

Notes & caveats

Optional arguments for tokenize()

You may have noticed that the tokenize() method has two optional arguments: controller and token:

export function tokenize({
  controller = new AbortController(),
  token = unique()
}={}) {
  // ...
}

AbortController

The idea is that this could be useful if you already have an AbortController that could be responsible for cancelling multiple chains. Here's an example of that use-case in Preact where the component starts two tasks (each a chain) in parallel, but you want to abort both when the component unmounts.

class Thing extends Component {

  constructor() {
    this._abortController = new AbortController();
  }

  componentDidMount() {
    taskA();
    taskB();
  }

  componentWillUnmount() {
    this._abortController.abort();
  }

  taskA() {

    const { _abortControler: controller } = this;
    const { token } = tokenize({ controller });

    Promise.resolve()
    .then( () => {
      throwIfAborted( token );
      // ...
    })
    .then( () => {
      throwIfAborted( token );
      // ...
    })
    .catch( error => { /* ... */ } )
    .finally( () => {
      cleanup( token );
    });

  }

  taskB() {
    // ...
  }

}

Token

Or you might define a constant value for a specific (set of) task(s):

const SOME_TASK = 'SOME_TASK';

function taskA() {
  
  // Create an AbortController associated with a
  // predefined token
  const { token } = tokenize({ token: SOME_TASK });

  Promise.resolve()
  .then( () => {
    // You know the drill by now, right?
  });

}

function taskB() {
  // ...
}

// Then, anywhere and anytime, abort both task A
// and task B
abort( SOME_TASK );

Caveats

  1. In most cases, you can get away with only one throwIfAborted() call at the top of .then() as javascript is single-threaded. Once the .then() has started executing, nothing1 can interfere as long as it's synchronous (and blocks the main thread from doing other things). If you do any async work in there, you should call throwIfAborted() again because you can't be sure if nothing has caused an abort while your async call was away, doing things:
Promise.resolve()
.then( async () => {
  throwIfAborted( token );
  const someValue = await getSomeValue(); // <-- async work!
  throwIfAborted( token ); // <-- need to check again!
})

1 Okay, Service- and Web Workers are a thing, probably other APIs that don't immediately pop into my head right now, but these would have to come back into the main thread before they can really interfere with