// 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 iffp
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 oftp
. - Developers should be able to cancel
fp
, which if not-settled, would result injp
andtp
rejecting withundefined
. - Developers should be able to cancel both
jp
andtp
,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 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 finally
s 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.
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();
textTrimPromise
is marked for cancelation.textTrimPromise
's parent,textPromise
has its ref count decreased by 1, to 0.textPromise
is marked for cancelation.textPromise
's parent,fetchPromise
has its ref count decreased by 1, to 1.textPromise
, the parent-most in the chain marked for cancelation, immediately cancels (does not wait forfetchPromise
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();
jsonPromise
is marked for cancelation.jsonPromise
's parent,fetchPromise
has its ref count decreased by 1, to 0.fetchPromise
is marked for cancelation.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.
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();
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.textPromise
is marked for cancelationtextPromise
's parent,innerFetchPromise
has its ref count decreased by 1, to 0.innerFetchPromise
is marked for cancelation.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();
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.
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.
// 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();
// 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.
// 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();
// 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();
// 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());
}
}());
// 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 }
);
});
Looking good. A few thoughts:
Given your onCanceled design, I'd say by default cancelation does nothing, and if the creator of the promise wants it to do something, they call resolve or reject.
Alternately... some kind of thing where we add
Promise.prototype.finally
(with the usual semantics), but also say that cancellation can be caught by a finally but not by a catch or then? This kind of fits with generators'return
method, and with analogous sync scenarios likeI'm not sure whether this is workable. If it is I'm not sure whether we make
Promise.prototype.finally
able to catch cancellations, or onlyCancelablePromise.prototype.finally
. (Maybe it doesn't matter.)return fn
.CancelablePromise.prototype.then
returns a newCancelablePromise
whoseonCanceled
decreases the ref count.