Skip to content

Instantly share code, notes, and snippets.

@pygy
Last active May 9, 2024 13:27
Show Gist options
  • Save pygy/6290f78b078e22418821b07d8d63f111 to your computer and use it in GitHub Desktop.
Save pygy/6290f78b078e22418821b07d8d63f111 to your computer and use it in GitHub Desktop.
You can already cancel ES6 Promises

The gist: by having a Promise adopt the state of a forever pending one, you can suspend its then handlers chain.

Promise.pending = Promise.race.bind(Promise, [])

let cancel

new Promise(function(fulfill, reject) {
  cancel = function() {fulfill(Promise.pending())}
  setTimeout(fulfill, 1000, 5)
}).then(console.log)

cancel() // 5 is never logged.

Also, mid-chain:

someAsyncJob().then(result => {
  if (result === 'enough') return Promise.pending()
  return moreAsyncWork()
}).then(otherResult => {
  // won't run if result was 'enough'
})

Possible implementations/API:

Taking a page fron Bluebird (but without canceling parents up the chain)

function cancellablePromise(executor) {
  let cancel
  var res = new Promise(function(fulfill, reject) {
    let handler
    function onCancel(cb) {handler = cb}
    cancel = function cancel() {
      fulfill(Promise.pending()) // adopt a forever pending state
      if (typeof handler === 'function') handler()
    }
    executor(fulfill, reject, onCancel)
  })
  res.cancel = cancel
  return res
}

Alternatively

function cancellablePromise(executor) {
  return new Promise(function(fulfill, reject) {
    function cancel() {fulfill(Promise.pending())}
    executor(fulfill, reject, cancel)
  })
}

Given all the hubub around cancellable promises, I'm sure I'm missing something...

@leihuang23
Copy link

I think we have a way to actually aborting a Promise. Check this out: developer.mozilla.org/en-US/docs/Web/API/AbortController

If you read the introduction in the doc carefully, you'll notice that AbortController is for canceling a network request, not a promise. A request is a platform activity, whereas a promise is a language construct.

@anhtuank7c
Copy link

Ah right

@caiquegaspar
Copy link

caiquegaspar commented Apr 24, 2023

(Updated on 04/26/2023)
Here is my attempt:

const timeoutArr = [];

async function usePromiseTimeout({
  delay = 0,
  callBackFn = null,
  abortController = null,
  str = 'Default sucess message',
  reason = null,
}) {
  const idx = timeoutArr.length;
  let abort = false;

  if (abortController) abortController.idxs.push(idx);

  const abortFn = () => {
    abort = true;
    clearTimeout(timeoutArr[idx].timeout);
    queueMicrotask(() => timeoutArr[idx].resolve());
  };

  const abortFnProxy = new Proxy(abortFn, {
    apply(target, thisArg, args) {
      target.apply(thisArg, args);
    },
  });

  const resolveTimeout = ({ resolve, reject }) => {
    if (reason !== null || abort) {
      reject(reason);
      // throw new Error('test'); // if necessary
      return;
    }

    if (callBackFn) callBackFn();

    resolve(str);
  };

  return new Promise(
    (resolve, reject) =>
      (timeoutArr[idx] = {
        resolve: () => resolveTimeout({ resolve, reject }),
        timeout: setTimeout(() => resolveTimeout({ resolve, reject }), delay),
        abort: abortFnProxy,
      })
  );
}

function useAbortController() {
  return {
    idxs: [],
    abort() {
      this.idxs.forEach((idx) => {
        timeoutArr[idx].abort();
      });
    },
  };
}

So it can be implemented like this:

console.log('start');

const ctrl1 = useAbortController();
const ctrl2 = useAbortController();
const ctrl3 = useAbortController();

// timeout 1
usePromiseTimeout({
  delay: 3000,
  abortController: ctrl1,
})
  .then((result) => console.log('finish promise 1', result))
  .finally(() => console.log('finally test promise 1'));

// timeout 2
(async () =>
  await promiseTimeout({
    delay: 3000,
    callBackFn: () => console.log('callback function2'),
    abortController: ctrl2,
  }))();

// timeout 3
(async () =>
  await promiseTimeout({
    delay: 3000,
    callBackFn: () => console.log('callback function3'),
    abortController: ctrl3,
  }))();

// timeout 4
(async () =>
  await promiseTimeout({
    delay: 3000,
    callBackFn: () => console.log('callback function4'),
    abortController: ctrl1, // same controller of promise 1
  }))();

setTimeout(() => ctrl1.abort(), 2000);
setTimeout(() => ctrl2.abort(), 2000);

And output is:

start

finally test promise 1

callback function3

Timeout 1 was aborted.
Timeout 1 .finally() runs even if the timeout is aborted.
Timeout 2 was aborted.
Timeout 3 completed after 3 seconds.
Timeout 4, which uses the same AbortController than timeout 1, was aborted.

Here the same AbortController can abort multiple promises


Typescript syntax:

interface PromiseTimeoutInterface {
  delay: number;
  callBackFn?: () => any;
  abortController?: AbortControllerInterface;
  str: string;
  reason?: string;
}

interface AbortControllerInterface {
  idxs: number[];
  abort(): void;
}

type PromiseParams = {
  resolve: (value: string | PromiseLike<string>) => void;
  reject: (reason?: any) => void;
};

const timeoutArr: { resolve(): void; timeout: number; abort(): void }[] = [];

export async function usePromiseTimeout({
  delay = 0,
  callBackFn,
  abortController,
  str = "Timeout ended!",
  reason,
}: PromiseTimeoutInterface): Promise<string> {
  const idx = timeoutArr.length;
  let abort = false;

  if (abortController) abortController.idxs.push(idx);

  const abortFn = () => {
    abort = true;
    clearTimeout(timeoutArr[idx].timeout);
    queueMicrotask(() => timeoutArr[idx].resolve());
  };

  const abortFnProxy = new Proxy(abortFn, {
    apply(target, thisArg, args: []) {
      target.apply(thisArg, args);
    },
  });

  const resolveTimeout = <A extends PromiseParams>({ resolve, reject }: A) => {
    if (reason || abort) {
      reject(reason);
      // or
      // throw new Error(reason)

      return;
    }

    if (callBackFn) callBackFn();

    resolve(str);
  };

  return new Promise(
    (resolve, reject) =>
      (timeoutArr[idx] = {
        resolve: () => resolveTimeout({ resolve, reject }),
        timeout: setTimeout(() => resolveTimeout({ resolve, reject }), delay),
        abort: abortFnProxy,
      })
  );
}

export function useAbortController(): AbortControllerInterface {
  return {
    idxs: [],
    abort() {
      this.idxs.forEach((idx) => {
        timeoutArr[idx].abort();
      });
    },
  };
}

Link to see it in action:
https://stackblitz.com/edit/promise-timeout-n-abort?file=index.js

@king-of-poppk
Copy link

let cancel

new Promise(function(resolve, reject) {
  cancel = () => reject(new PromiseCancelledError())}
  setTimeout(resolve, 1000, 5)
}).then(console.log, (error) => {
  if (!(error instanceof PromiseCancelledError)) { ... }
})

cancel() // 5 is never logged.

@alecat88
Copy link

alecat88 commented Oct 5, 2023

Seems to me that all of the above creates a memory leak, or am I wrong?

@king-of-poppk
Copy link

Seems to me that all of the above creates a memory leak, or am I wrong?

Mine just above should not leak if you discard the reference to cancel and clear the timeout once you do not need it anymore. Of course you cannot "clear the timeout" in the general case. Each async "leg" of a promised computation should have a way to test whether it should stop instead of continuing the promise callback chain and each truly async operation (like fetch or setTimeout) should have a real "abort" method that interrupts the request (through AbortController/clearTimeout). It seems everything can be centralized as is done in the solutions above but it still requires each "leg" to be connected to that central piece of information.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment