mw.Api methods all return abortable promises (that is, jQuery.Promise objects, extended with an additional #abort method, which cancels any in-flight HTTP request and rejects the promise). This is very convenient for common use cases where the caller no longer wants the result of a request they were waiting for, and aborts it and replaces it with a new one – for example, in an autocompletion search, when the user has typed a new search term.
// 1: Abortable with no extra effort function getStuff1() { var api = new mw.Api(); return api.get( { meta: 'userinfo' } ); } var myPromise = getStuff1(); myPromise.abort();
However, it's very inconvenient when you need to chain such promises, because calling #then will return another promise that no longer has an #abort method, and you need to extend it yourself. If you just need to post-process the result, and keep the option to abort the request, the required code is still bearable compared to the naive version that loses abortability:
// 2A: Not abortable function getStuff2A() { var api = new mw.Api(); return api.get( { meta: 'userinfo' } ).then( function ( resp ) { return resp.query.userinfo.name; } ); } // 2B: Abortable function getStuff2B() { var api = new mw.Api(); var abortable = api.get( { meta: 'userinfo' } ); return abortable.then( function ( resp ) { return resp.query.userinfo.name; } ).promise( { abort: abortable.abort } ); }
However, if you need to chain multiple API requests, it takes an absurd amount of bookkeeping to make sure that #abort actually cancels whichever HTTP request is currently in progress, and rejects the final promise, especially if you compare it to how simple the naive version is:
// 3A: Not abortable function getStuff3A() { var api = new mw.Api(); return api.get( { meta: 'userinfo' } ).then( function ( resp ) { var name = resp.query.userinfo.name; return api.get( { list: 'usercontribs', ucuser: name } ); } ).then( function ( resp ) { return resp.query.usercontribs.length; } ); } // 3B: Abortable // (if you're willing to cut some corners, and fail to reject in edge cases, this can be a little simpler) function getStuff3B() { var api = new mw.Api(); var abortedPromise = $.Deferred().reject( 'http', { textStatus: 'abort', exception: 'abort' } ); var abortable; var aborted; abortable = api.get( { meta: 'userinfo' } ); return abortable.then( function ( resp ) { if ( aborted ) { return abortedPromise; } var name = resp.query.userinfo.name; abortable = api.get( { list: 'usercontribs', ucuser: name } ); return abortable; } ).then( function ( resp ) { if ( aborted ) { return abortedPromise; } return resp.query.usercontribs.length; } ).promise( { abort: function () { aborted = true; if ( abortable ) { abortable.abort(); } } } ); }
I was so peeved by this after writing https://gerrit.wikimedia.org/r/c/mediawiki/extensions/VisualEditor/+/938958 recently that I went on a quest for a better method. It turns out that the Fetch API has a cancellation mechanism that works much better for this use case, and that is easy to "backport" into mw.Api. (There are other benefits and drawbacks, which are boring, you can probably find blog posts about them somewhere.)
One notable drawback is that it's a bit more verbose for the simplest cases, so I think we should support it in addition to abortable promises, and we should not deprecate them.
// 2C: Abortable with signals – a bit more verbose than version 2B function getStuff2C() { var api = new mw.Api(); var abort = new AbortController(); var ajaxOptions = { signal: abort.signal }; return api.get( { meta: 'userinfo' }, ajaxOptions ).then( function ( resp ) { return resp.query.userinfo.name; } ).promise( abort ); } // 3C: Abortable with signals – much less verbose than version 3B function getStuff3C() { var api = new mw.Api(); var abort = new AbortController(); var ajaxOptions = { signal: abort.signal }; return api.get( { meta: 'userinfo' }, ajaxOptions ).then( function ( resp ) { var name = resp.query.userinfo.name; return api.get( { list: 'usercontribs', ucuser: name }, ajaxOptions ); } ).then( function ( resp ) { return resp.query.usercontribs.length; } ).promise( abort ); }
One notable benefit is that this mechanism also works in code using async/await instead of explicit #then, while abortable promises don't (because they are subsumed by native promises, and there's no way to extend those with an #abort method).
// 4C: Abortable with signals and async-await async function getStuff4C( ajaxOptions ) { const api = new mw.Api(); const resp = await api.get( { meta: 'userinfo' }, ajaxOptions ); const name = resp.query.userinfo.name; const resp2 = await api.get( { list: 'usercontribs', ucuser: name }, ajaxOptions ); return resp2.query.usercontribs.length; } let abort = new AbortController(); let myPromise = getStuff4C( { signal: abort.signal } ); abort.abort(); await myPromise;