Skip to content

Instantly share code, notes, and snippets.

@jakearchibald
Last active February 2, 2016 10:25
Show Gist options
  • Save jakearchibald/9a24f3c06f06b9c06a1e to your computer and use it in GitHub Desktop.
Save jakearchibald/9a24f3c06f06b9c06a1e to your computer and use it in GitHub Desktop.

Options for cancelling promises

Requirements from fetch

// fetch returns a promise
var fp = fetch(url);
  • Developers should be able to cancel the network request triggered by fetch.
  • If the fetch operation is cancelled before fp settles, the promise should settle in a way that signals cancelation.
  • If the fetch operation is cancelled before fp settles, but completes before it's able to cancel, fp should still cancel.
var fp = fetch(url);
// r.json streams the response body from the network,
// parsing it as json
var jp = fp.then(r => r.json());
  • Developers should be able to cancel the whole operation of jp, meaning if fp has yet to settle, the fetch is cancelled.
// fetch user data & store the response
var getUserData = (function() {
  var fp;
  return function() {
    if (!fp) {
      fp = fetch('//user-data-url').catch(err => {
        fp = undefined;
        throw err;
      });
    }
    return fp.then(r => r.clone());
  }
}());

var jp = getUserData().then(r => r.json());
var tp = getUserData().then(r => r.text());
  • Developers should be able to cancel jp, which would prevent/cancel the stream-read-as-JSON if it hasn't already completed, but it shouldn't affect the resolution of tp.
  • Developers should be able to cancel fp, which if not-settled, would result in jp and tp rejecting with undefined.
  • Developers should be able to cancel both jp and tp, fp should be able to react to this loss of interest by also cancelling.
// in a ServiceWorker
self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(url1).then(r => r.json()).then(data => {
      return fetch(data.url2);
    })
  );
});
  • The browser should be able to signal loss-of-interest in the promise passed to respondWith, resulting in the cancelation of the parent-most unsettled promise.

Finally

Finally should be added to regular promises:

Promise.reject(Error("Boo")).finally(_ => {
  console.log("Finally!");  
}).then(_ => {
  console.log("Yey");
});
// logs: "Finally!", does not log "Yey"

The return value from finally has no impact, but multiple finallys can be applied:

Promise.resolve().then(_ => {
  console.log("Resolved 1");
  throw Error("Boo");
}).finally(_ => {
  console.log("Finally 1");
}).finally(_ => {
  console.log("Finally 2");
}).then(_ => {
  console.log("Resolved 2");
}, err => {
  console.log("Rejected 1");
}).finally(_ => {
  console.log("Finally 3");
});
// logs: "Resolved 1", "Finally 1", "Finally 2", "Rejected 1", "Finally 3"

This is useful for always-actions, such as hiding spinners.

Cancelable promise design

This is an iteration on the proposal in the cancelable fetch centithread clustermisery.

Cancelability uses a ref-counting approach, where the references are based on promise-chain dependency:

var fetchPromise = fetch(url);
// Ref counts
// fetchPromise: 0

var jsonPromise = fetchPromise.then(response => {
  var innerJSONPromise = response.clone().json();
  return innerJSONPromise;
});
// Ref counts
// fetchPromise: 1
// jsonPromise:  0

var textPromise = fetchPromise.then(response => {
  var innerTextPromise = response.clone().text();
  return innerTextPromise;
});

var textTrimPromise = textPromise.then(t => t.trim()).finally(_ => console.log("Finally!"));
// Ref counts
// fetchPromise:    2
// jsonPromise:     0
// textPromise:     1
// textTrimPromise: 0

Then, assuming that fetchPromise has not settled:

textTrimPromise.cancel();
  1. textTrimPromise is marked for cancelation.
  2. textTrimPromise's parent, textPromise has its ref count decreased by 1, to 0.
  3. textPromise is marked for cancelation.
  4. textPromise's parent, fetchPromise has its ref count decreased by 1, to 1.
  5. textPromise, the parent-most in the chain marked for cancelation, immediately cancels (does not wait for fetchPromise to resolve).

When a cancelable promise is canceled, neither its onFulfil or onReject handlers are called, but its onFinally handler will call. All child promises are also canceled. In the above code, "Finally" will be logged.

Then:

jsonPromise.cancel();
  1. jsonPromise is marked for cancelation.
  2. jsonPromise's parent, fetchPromise has its ref count decreased by 1, to 0.
  3. fetchPromise is marked for cancelation.
  4. fetchPromise, is the parent-most in the chain marked for cancelation. As its work has begun, it gets to react to this cancelation and abort the underlying stream.

Handling promises resolved with promises

var outerFetchPromise = fetch(url);
var resultPromise = outerFetchPromise.then(response => {
  var jsonPromise = response.json();
  var innerFetchPromise = jsonPromise.then(data => fetch(data.url));
  var textPromise = innerFetchPromise.then(r => r.text());
  return textPromise;
});

Assuming outerFetchPromise has resolved with a Response, and jsonPromise has resolved with an object, our ref-counts are:

// resultPromise:     0
// innerFetchPromise: 1
// textPromise:       1

textPromise has a ref-count of 1 because it's the resolved value of resultPromise.

Then:

resultPromise.cancel();
  1. resultPromise cannot be marked for cancelation as it has resolved. But since it hasn't settled, and its resolved value, textPromise, is a cancelable promise, it has its ref count decreased by 1, to 0.
  2. textPromise is marked for cancelation
  3. textPromise's parent, innerFetchPromise has its ref count decreased by 1, to 0.
  4. innerFetchPromise is marked for cancelation.
  5. innerFetchPromise, is the parent-most in the chain marked for cancelation. As its work has begun, it gets to react to this cancelation and abort the underlying request.

A gotcha with resolved-but-unsettled promises:

Take the following nonsensical example:

var fetchPromise = fetch(url);
var jsonPromise = fetchPromise.then(r => r.json());
var fetchPromise2 = fetch(url2);
var waitingPromise = fetchPromise2.then(_ => fetchPromise);
// Ref counts
// fetchPromise:   2
// jsonPromise:    0
// waitingPromise: 0

Imagine fetchPromise has yet to settle, fetchPromise2 has fulfilled with a Response, waitingPromise has resolved with fetchPromise. Then:

waitingPromise.cancel();
  1. waitingPromise cannot be marked for cancelation as it has resolved. But since it hasn't settled, its resolved value, fetchPromise has its ref count decreased by 1, to 1.

And there's the problem. Although .cancel was called on this unsettled promise, it continues to resolve with fetchPromise.

This wouldn't be a problem with:

var fetchPromise = fetch(url);
var jsonPromise = fetchPromise.then(r.clone() => r.json());
var fetchPromise2 = fetch(url2);
var waitingPromise = fetchPromise2.then(_ => fetchPromise).then();
// Ref counts
// fetchPromise:   2
// jsonPromise:    0
// waitingPromise: 0

…as waitingPromise hasn't resolved due to the added .then(). I think we either need to find a way to do something like that in the spec (maybe it does already?), or allow a resolved but unsettled promise to cancel. Both of these would also help if a CancelablePromise resolved with a foreign thenable.

I'm having trouble getting my head around this bit of the spec, so there may be a simpler answer.

Creating a cancelable promise

var p = new CancelablePromise((resolve, reject) => {
  var timerID = setTimeout(_ => resolve("Done!"), 5000);

  return function onCancel() {
    clearTimeout(timerID);
  });
});

The function returned from CancelablePromise's first argument is called if this promise is cancelled before settling, but after onCancel was called.

Question: If the function passed to onCancel calls resolve or reject synchronously, should this value become the settled value? Since we've switched to the .finally approach, I think not.

Comparison with the token approach

Creating and cancelling a promise

// Cancelable promises
var p = new CancelablePromise((resolve, reject, onCancel) => {
  var timerID = setTimeout(_ => resolve("Done!"), 5000);

  return _ => {
    clearTimeout(timerID);
  });
});
p.cancel();
// Cancelable token
var cts = new CancellationTokenSource();
var p = new Promise((resolve, reject) => {
  var timerID = setTimeout(_ => resolve("Done!"), 5000);

  cts.token.register(_ => {
    clearTimeout(timerID);
  });
});
cts.cancel();

Canceling a promise chain

// Cancelable promises

startSpinner();
var p = fetch(url)
  .then(r => r.json())
  .then(data => fetch(data.url))
  .then(r => text())
  .then(text => updateUI(text))
  .catch(err => showUIError())
  .finally(stopSpinner);
p.cancel();
// Cancelable token
startSpinner();
var cts = new CancellationTokenSource();
var p = fetch(url, { cancelationToken: cts.token })
  .then(r => r.json({ cancelationToken: cts.token }))
  .then(data => fetch(data.url, { cancelationToken: cts.token }))
  .then(r => text({ cancelationToken: cts.token }))
  .then(text => updateUI(text))
  .catch(err => showUIError(err))
  .finally(stopSpinner);
cts.cancel();
stopSpinner();

Note: I'm assuming with the token approach, it'll leave my promise chain in a pending state, so I'll have to call stopSpinner() post-cancelation. I'm also relying on the canceled item not to call reject() with some kind of abort error. I'll assume that leaving promises pending is the convention & everything in the chain does that.

Canceling multiple things at once

// Cancelable promises
var p = CancelablePromise.all(
  [url1, url2, url3].map(url => {
    return fetch(url).then(r => r.json());
  })
)

p.cancel();
// Cancelable token
var cts = new CancellationTokenSource();
var p = Promise.all(
  [url1, url2, url3].map(url => {
    return fetch(url, { cancelationToken: cts.token })
      .then(r => r.json({ cancelationToken: cts.token }));
  })
)

cts.cancel();

Canceling parent once all children have cancelled

// Cancelable promises
var getUserData = (function() {
  var fp;
  return function() {
    if (!fp) {
      fp = fetch('//user-data-url').catch(err => {
        fp = undefined;
        throw err;
      });
    }
    return fp.then(r => r.clone());
  }
}());

var jp = getUserData().then(r => r.json());
var tp = getUserData().then(r => r.text());

jp.cancel();
tp.cancel();
// Cancelable token
var getUserData = (function() {
  var fp;
  var tokenSet = new Set();
  var cts;
  return function({ cancelationToken }) {
    if (!fp) {
      cts = new CancellationTokenSource();
      tokenSet.clear();
      fp = fetch('//user-data-url', {
        cancelationToken: cts.token
      }).catch(err => {
        fp = undefined;
        throw err;
      });
    }

    // Using a set because multiple calls with the
    // same cancelationToken should count at one.
    if (cancelationToken && !tokenSet.has(cancelationToken)) {
      cancelationToken.register(_ => {
        tokenSet.remove(cancelationToken);
        if (tokenSet.size === 0) {
          cts.cancel();
        }
      });
    }

    // If this is called without a cancelationToken,
    // it should still increase the 'refcount'
    tokenSet.add(cancelationToken || new CancellationTokenSource().token);

    return fp.then(r => r.clone());
  }
}());

var cts1 = new CancellationTokenSource();
var jp = getUserData({ cancelationToken: cts1.token }).then(r => r.json({ cancelationToken: cts1.token }));
var cts2 = new CancellationTokenSource();
var tp = getUserData({ cancelationToken: cts2.token }).then(r => r.text({ cancelationToken: cts2.token }));

cts1.cancel();
cts2.cancel();

Keeping cancelation to yourself

// Cancelable promises
var getUserData = (function() {
  var fp;
  return function() {
    if (!fp) {
      fp = fetch('//user-data-url').catch(err => {
        fp = undefined;
        throw err;
      });
    }
    return Promise.resolve(fp.then(r => r.clone()));
  }
}());

Question: Should there be an easier way to become uncancelable than Promise.resolve(cancelablePromise)? cancelablePromise.protect() could be a shortcut.

// Cancelable token
var getUserData = (function() {
  var fp;
  var cts;
  return function() {
    if (!fp) {
      cts = new CancellationTokenSource();
      fp = fetch('//user-data-url', {
        cancelationToken: cts.token
      }).catch(err => {
        fp = undefined;
        throw err;
      });
    }
    return fp.then(r => r.clone());
  }
}());

Giving the promise-receiver cancelation ability

// Cancelable promises
self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(url1).then(r => r.json()).then(data => {
      return fetch(data.url2);
    })
  );
});
// Cancelable token
self.addEventListener('fetch', event => {
  var cts = new CancellationTokenSource();
  event.respondWith(
    fetch(url1, {
      cancelationToken: cts.token
    }).then(r => r.json({
      cancelationToken: cts.token
    })).then(data => {
      return fetch(data.url2, {
        cancelationToken: cts.token
      });
    }),
    { canceller: cts }
  );
});
@jed
Copy link

jed commented Sep 23, 2015

lest we end up with another referer debacle, we should probably pick a side in the Canceled vs. cancelled debate.

Here are the current stats on this page:

Cancellation: |||||||||||||||||||||||||||||||||||||||||||||||||||||| (54)
Cancelation: ||||||||||||||||||||||||||||||||||||||||||||||||| (49)

@machty
Copy link

machty commented Sep 23, 2015

@jed could you open an RFC at https://github.com/english/rfcs ?

@jakearchibald
Copy link
Author

Hah, well it should be CancelablePromise due to existing APIs like https://developer.mozilla.org/en-US/docs/Web/API/Event/cancelable - that was already decided and I did a find/replace in the OP.

As for CancellationToken vs CancelationToken - I don't really care, they were only used as a comparison of the method, I could have called them ThatllDoPigToken. "Cancelation" is uncommon outside the US, but whatever. Although my personal writings will remain en-gb.

@dlindahl
Copy link

The spelling distinction extends to cancelers and cancellers, as well as to cancelable and cancellable, but it does not not extend to cancellation, which everywhere is spelled with two l’s.
-- http://grammarist.com/spelling/cancel/

"canceled" is supposed to be a US thing whereas "cancelled" is a British imperial thing. But I've had a similar discussion on a previous dev team and found that "cancelled" seems to be used more colloquially in the US (in a purely unscientific study of things I've noticed with a certain degree of selection bias)

There are dozens of blog posts about this: https://www.google.com/search?q=canceled+vs+cancelled&oq=canceled+vs+cancelled&aqs=chrome..69i57.6512j0j7&sourceid=chrome&es_sm=91&ie=UTF-8. Its likely a un-winnable argument so if there is already a precedent (as in the case of the previously linked Event API), then it is probably safer to go with that form.

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