Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support abort API #592

Merged
merged 27 commits into from
May 23, 2018
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
93e9cec
Support abort API
jamesplease Dec 7, 2017
53565c1
Support abort without replacing native fetch
jamesplease Dec 7, 2017
9a7d232
Remove event listener; use request.signal
jamesplease Dec 7, 2017
fd5c261
Skip creation of XHR if signal is already aborted
jamesplease Dec 7, 2017
318e95f
First pass at documentation
jamesplease Dec 8, 2017
0e1b651
More tweaks based on feedback
jamesplease Dec 8, 2017
d47f6c9
Remove some semicolons
jamesplease Dec 8, 2017
529c9fa
Add `signal` property to Request instead of Response
mislav Dec 13, 2017
8ba0cdb
Polyfill DOMException if missing
mislav Dec 13, 2017
074c89e
Polyfill DOMException
mislav Dec 13, 2017
b832229
Add tests for aborting requests
mislav Dec 13, 2017
c6aff30
Avoid creating XMLHttpRequest if we're immediately aborting
mislav Dec 13, 2017
d9aa590
Clear event listener for all xhr completion status.
joaovieira Feb 2, 2018
886a076
Merge pull request #1 from joaovieira/abort-polyfill
joaovieira Feb 2, 2018
9f9a00f
Test signal reuse. Add AbortSignal polyfill.
joaovieira Feb 2, 2018
bab0f3d
Consistent implementation of EventTarget as in MDN.
joaovieira Feb 2, 2018
0bed2ba
Split EventTarget and AbortSignal polyfills.
joaovieira Feb 3, 2018
66ed8ba
Merge pull request #2 from joaovieira/abort-polyfill
joaovieira Feb 3, 2018
738d51a
Add tests with signal within Request object.
joaovieira Feb 3, 2018
8f778f3
Fix EventTarget callback invocation context.
joaovieira Feb 3, 2018
f0e24f8
Native EventTarget is not inheritable.
joaovieira Feb 3, 2018
36bf692
Replace AbortSignal polyfill with abortcontroller-polyfill module.
joaovieira Feb 4, 2018
7f6b8d2
Upgrade abortcontroller-polyfill.
joaovieira Feb 4, 2018
c31fca9
Merge remote-tracking branch 'origin/master' into abort-polyfill
mislav May 23, 2018
f035860
Scope abortcontroller to polyfill only
mislav May 23, 2018
2bd7f22
Export DOMException for `instanceof` checks
mislav May 23, 2018
c873843
Polish documentation for aborting requests
mislav May 23, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .jshintrc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"worker": true,
"globals": {
"JSON": false,
"URLSearchParams": false
"URLSearchParams": false,
"AbortController": false
}
}
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ expected to uphold this code.
* [Sending cookies](#sending-cookies)
* [Receiving cookies](#receiving-cookies)
* [Obtaining the Response URL](#obtaining-the-response-url)
* [Aborting requests](#aborting-requests)
* [Browser Support](#browser-support)

## Read this first
Expand Down Expand Up @@ -260,6 +261,36 @@ response.headers['X-Request-URL'] = request.url
This server workaround is necessary if you need reliable `response.url` in
Firefox < 32, Chrome < 37, Safari, or IE.

#### Aborting requests

This polyfill supports
[the abortable fetch API](https://developers.google.com/web/updates/2017/09/abortable-fetch).
However, aborting a fetch requires use of two additional DOM APIs:
[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
and
[AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal).
Typically, browsers that do not support fetch will also not support
AbortController or AbortSignal. Consequently, you will need to include an
additional polyfill for these APIs to abort fetches.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's a polyfill that we audited and recommend using, then we should link to it here.


Once you have an AbortController and AbortSignal polyfill in place, you can
abort a fetch like so:

```js
const controller = new AbortController()

fetch('/avatars', {
signal: controller.signal
}).catch(function(ex) {
if (ex.name === 'AbortError') {
console.log('request aborted')
}
})

// some time later...
controller.abort();
```

## Browser Support

- Chrome
Expand Down
40 changes: 40 additions & 0 deletions fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@
}
this.method = input.method
this.mode = input.mode
this.signal = input.signal
if (!body && input._bodyInit != null) {
body = input._bodyInit
input.bodyUsed = true
Expand All @@ -331,6 +332,7 @@
}
this.method = normalizeMethod(options.method || this.method || 'GET')
this.mode = options.mode || this.mode || null
this.signal = options.signal || this.signal
this.referrer = null

if ((this.method === 'GET' || this.method === 'HEAD') && body) {
Expand Down Expand Up @@ -415,15 +417,38 @@
return new Response(null, {status: status, headers: {location: url}})
}

var DOMException = self.DOMException
try {
new DOMException()
} catch(err) {
DOMException = function(message, name) {
this.message = message
this.name = name
var error = Error(message)
this.stack = error.stack
}
DOMException.prototype = Object.create(Error.prototype)
DOMException.prototype.constructor = DOMException
}

self.Headers = Headers
self.Request = Request
self.Response = Response

self.fetch = function(input, init) {
return new Promise(function(resolve, reject) {
var request = new Request(input, init)

if (request.signal && request.signal.aborted) {
return reject(new DOMException('Aborted', 'AbortError'))
}

var xhr = new XMLHttpRequest()

function abortXhr() {
xhr.abort()
}

xhr.onload = function() {
var options = {
status: xhr.status,
Expand All @@ -443,6 +468,10 @@
reject(new TypeError('Network request failed'))
}

xhr.onabort = function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to attach onabort callback? The promise can be rejected within abortXhr() just as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mislav I believe this is the most correct as mentioned here. Calling abort on the XHR will set the right status code and readyState (https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with xhr.abort(). I'm just saying that we don't need to install the xhr.onabort callback just to reject the promise. We know that abortXhr() will be executed, so we can reject the promise in there.

Copy link
Contributor

@joaovieira joaovieira Feb 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see.. xhr is never exposed so it can't be aborted from the outside. Is there any other way it could be aborted (e.g. by the browser)? If not, there shouldn't be any difference. I don't mind this way though, reads well :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onabort wasn't needed until now, and since we control literally the only place from which the xhr is aborted, I don't think adding one level of indirection on top of abortXhr is necessary.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't onAbort() also confirm that xhr.abort() was successful vs. just trusting the results of abortXhr()?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dysonpro That's correct, but we aren't interested in whether xhr.abort() was performed successfully or not. We want to reject the promise right away because we know for sure an abort was requested.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mislav for your explanation. I am excited about this functionality so I have been following this PR carefully. I am already using this branch code in a project that requires aborting no longer needed requests.
Your point is that you want to ensure the promise is rejected, correct? I feel that it is closer to the core intention of the PR (and my own project as well) to ensure that the abort was completed successfully. Is there a way to do that without utilizing the xhr.onAbort() callback? Without that I feel that there could be a gap in the verification of the functionality for every platform and environment. Thanks again for your feedback.

Copy link
Contributor

@mislav mislav Feb 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dysonpro Your concerns are valid, but we have different priorities when thinking about this. At the point when abortXhr() is invoked (which can only happen upon AbortSignal), I consider the request most definitely discarded. We can then call xhr.abort(); reject(...) all at once. Now, if there is a browser which would somehow fail to actually abort the XHR when xhr.abort() is called, and thus never invoke onabort callback, that is fine with me because we already ensured that the promise is rejected. I don't expect this case to happen often (or ever, for that matter) and so I'm not concerned with it. I also don't expect onabort happening without us explicitly requesting it (after all, it wasn't needed until now). The code change that I suggested, when all is said and done, is mostly stylistic.

Copy link

@MattiasBuelens MattiasBuelens May 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any other way it could be aborted (e.g. by the browser)? If not, there shouldn't be any difference.

  • A browser extension (e.g. an ad blocker) could abort a request.
  • A service worker could intercept the fetch and replace it with a new request that has (or will have) an aborted signal.

These are rather obscure use cases, but they're real nonetheless. I think a general fetch polyfill should handle these, so it should listen for xhr.onabort.

There's nothing wrong with immediately rejecting after calling xhr.abort(), but it should also reject from xhr.onabort.

reject(new DOMException('Aborted', 'AbortError'))
}

xhr.open(request.method, request.url, true)

if (request.credentials === 'include') {
Expand All @@ -459,6 +488,17 @@
xhr.setRequestHeader(name, value)
})

if (request.signal) {
request.signal.addEventListener('abort', abortXhr)

xhr.onreadystatechange = function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now attach both xhr.onload/onerror and xhr.onreadystatechange style of callbacks. It's not a huge issue, but would it be possible to just one one style or the other?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the cleanest/safest way to know when the request has finished, regardless of the result, instead of having to do so on every callback:

xhr.onload = function() {
  ...
  request.signal.removeEventListener('abort', abortXhr)
}

xhr.onabort = function() {
  ...
  request.signal.removeEventListener('abort', abortXhr)
}

xhr.onerror = function() {
  ...
  request.signal.removeEventListener('abort', abortXhr)
}

xhr.onprogress = function() {
  ...
  // should I remove listener? no.
}

// what other events there are? timeout?
...

having the removeListener close to addListener is also easier to read/maintain.

Copy link

@MattiasBuelens MattiasBuelens May 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another possibility is xhr.onloadend. This event is dispatched after the request completes, regardless of how completes (load, error, abort or timeout).

With onloadend, you also don't need to check readyState, since it's guaranteed to be done.

// DONE (success or failure)
if (xhr.readyState === 4) {
request.signal.removeEventListener('abort', abortXhr)
}
}
}

xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit)
})
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"repository": "github/fetch",
"license": "MIT",
"devDependencies": {
"abortcontroller-polyfill": "1.1.2",
"chai": "1.10.0",
"jshint": "2.8.0",
"mocha": "2.1.0",
Expand Down
6 changes: 6 additions & 0 deletions script/server
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ var routes = {
res.writeHead(204);
res.end();
},
'/slow': function(res) {
setTimeout(function() {
res.writeHead(200);
res.end();
}, 100);
},
'/error': function(res) {
res.destroy();
},
Expand Down
1 change: 1 addition & 0 deletions test/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
</script>

<script src="/node_modules/promise-polyfill/promise.js"></script>
<script src="/node_modules/abortcontroller-polyfill/dist/abortcontroller.js"></script>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can now import abortcontroller-polyfill/dist/abortcontroller-polyfill-only since v1.1.8

<script src="/test/test.js"></script>
<script src="/fetch.js"></script>

Expand Down
114 changes: 114 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,120 @@ suite('fetch method', function() {
})
})

suite('aborting', function() {
test('initially aborted signal', function() {
var controller = new AbortController()
controller.abort()

return fetch('/request', {
signal: controller.signal
}).then(function() {
assert.ok(false)
}, function(error) {
assert.equal(error.name, 'AbortError')
})
})

test('initially aborted signal within Request', function () {
var controller = new AbortController()
controller.abort()

var request = new Request('/request', { signal: controller.signal })

return fetch(request).then(function() {
assert.ok(false)
}, function(error) {
assert.equal(error.name, 'AbortError')
})
})

test('mid-request', function() {
var controller = new AbortController()

setTimeout(function() {
controller.abort()
}, 30)

return fetch('/slow', {
signal: controller.signal
}).then(function() {
assert.ok(false)
}, function(error) {
assert.equal(error.name, 'AbortError')
})
})

test('mid-request within Request', function() {
var controller = new AbortController()
var request = new Request('/slow', { signal: controller.signal })

setTimeout(function() {
controller.abort()
}, 30)

return fetch(request).then(function() {
assert.ok(false)
}, function(error) {
assert.equal(error.name, 'AbortError')
})
})

test('abort multiple with same signal', function() {
var controller = new AbortController()

setTimeout(function() {
controller.abort()
}, 30)

return Promise.all([
fetch('/slow', {
signal: controller.signal
}).then(function() {
assert.ok(false)
}, function(error) {
assert.equal(error.name, 'AbortError')
}),
fetch('/slow', {
signal: controller.signal
}).then(function() {
assert.ok(false)
}, function(error) {
assert.equal(error.name, 'AbortError')
})
])
})

test('does not leak memory', function() {
var controller = new AbortController()
var signal = controller.signal

// success
return fetch('/request', {
signal: signal
}).then(function() {
assert.deepEqual(signal.listeners['abort'], [])
}).then(function () {
// failure
return fetch('/boom', {
signal: signal
}).catch(function() {
assert.deepEqual(signal.listeners['abort'], [])
})
}).then(function () {
// aborted
setTimeout(function() {
signal.dispatchEvent({ type: 'abort' })
}, 30)

return fetch('/slow', {
signal: signal
}).catch(function() {
assert.deepEqual(signal.listeners['abort'], [])
})
})
})
})

suite('response', function() {
test('populates body', function() {
return fetch('/hello').then(function(response) {
Expand Down
1 change: 1 addition & 0 deletions test/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mocha.setup('tdd')
self.assert = chai.assert

importScripts('/node_modules/promise-polyfill/promise.js')
importScripts('/node_modules/abortcontroller-polyfill/dist/abortcontroller.js')
importScripts('/test/test.js')
importScripts('/fetch.js')

Expand Down