diff --git a/Examples/UIExplorer/XHRExample.android.js b/Examples/UIExplorer/XHRExample.android.js index ad2693d91..7cadaa4ff 100644 --- a/Examples/UIExplorer/XHRExample.android.js +++ b/Examples/UIExplorer/XHRExample.android.js @@ -29,7 +29,7 @@ var { var XHRExampleHeaders = require('./XHRExampleHeaders'); var XHRExampleCookies = require('./XHRExampleCookies'); var XHRExampleFetch = require('./XHRExampleFetch'); - +var XHRExampleOnTimeOut = require('./XHRExampleOnTimeOut'); // TODO t7093728 This is a simplified XHRExample.ios.js. // Once we have Camera roll, Toast, Intent (for opening URLs) @@ -297,6 +297,11 @@ exports.examples = [{ render() { return ; } +}, { + title: 'Time Out Test', + render() { + return ; + } }]; var styles = StyleSheet.create({ diff --git a/Examples/UIExplorer/XHRExample.ios.js b/Examples/UIExplorer/XHRExample.ios.js index d28244367..b84c8b0a0 100644 --- a/Examples/UIExplorer/XHRExample.ios.js +++ b/Examples/UIExplorer/XHRExample.ios.js @@ -32,6 +32,7 @@ var { var XHRExampleHeaders = require('./XHRExampleHeaders'); var XHRExampleFetch = require('./XHRExampleFetch'); +var XHRExampleOnTimeOut = require('./XHRExampleOnTimeOut'); class Downloader extends React.Component { state: any; @@ -331,6 +332,11 @@ exports.examples = [{ render() { return ; } +}, { + title: 'Time Out Test', + render() { + return ; + } }]; var styles = StyleSheet.create({ diff --git a/Examples/UIExplorer/XHRExampleOnTimeOut.js b/Examples/UIExplorer/XHRExampleOnTimeOut.js new file mode 100644 index 000000000..4fbd2aa82 --- /dev/null +++ b/Examples/UIExplorer/XHRExampleOnTimeOut.js @@ -0,0 +1,110 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @flow + */ +'use strict'; + +var React = require('react'); +var ReactNative = require('react-native'); +var { + StyleSheet, + Text, + TouchableHighlight, + View, +} = ReactNative; + +class XHRExampleOnTimeOut extends React.Component { + state: any; + xhr: XMLHttpRequest; + + constructor(props: any) { + super(props); + this.state = { + status: '', + loading: false + }; + } + + loadTimeOutRequest() { + this.xhr && this.xhr.abort(); + + var xhr = this.xhr || new XMLHttpRequest(); + + xhr.onerror = ()=> { + console.log('Status ', xhr.status); + console.log('Error ', xhr.responseText); + }; + + xhr.ontimeout = () => { + this.setState({ + status: xhr.responseText, + loading: false + }); + }; + + xhr.onload = () => { + console.log('Status ', xhr.status); + console.log('Response ', xhr.responseText); + }; + + xhr.open('GET', 'https://httpbin.org/delay/5'); // request to take 5 seconds to load + xhr.timeout = 2000; // request times out in 2 seconds + xhr.send(); + this.xhr = xhr; + + this.setState({loading: true}); + } + + componentWillUnmount() { + this.xhr && this.xhr.abort(); + } + + render() { + var button = this.state.loading ? ( + + + Loading... + + + ) : ( + + + Make Time Out Request + + + ); + + return ( + + {button} + {this.state.status} + + ); + } +} + +var styles = StyleSheet.create({ + wrapper: { + borderRadius: 5, + marginBottom: 5, + }, + button: { + backgroundColor: '#eeeeee', + padding: 8, + }, +}); + +module.exports = XHRExampleOnTimeOut; diff --git a/Libraries/Network/RCTNetworking.m b/Libraries/Network/RCTNetworking.m index 88c2de0e0..07b128fd9 100644 --- a/Libraries/Network/RCTNetworking.m +++ b/Libraries/Network/RCTNetworking.m @@ -385,6 +385,7 @@ RCT_EXPORT_MODULE() } NSArray *responseJSON = @[task.requestID, RCTNullIfNil(error.localizedDescription), + error.code == kCFURLErrorTimedOut ? @YES : @NO ]; [_bridge.eventDispatcher sendDeviceEventWithName:@"didCompleteNetworkResponse" diff --git a/Libraries/Network/XMLHttpRequestBase.js b/Libraries/Network/XMLHttpRequestBase.js index 784e00668..1c9633da2 100644 --- a/Libraries/Network/XMLHttpRequestBase.js +++ b/Libraries/Network/XMLHttpRequestBase.js @@ -61,6 +61,8 @@ class XMLHttpRequestBase { status: number; timeout: number; responseURL: ?string; + ontimeout: ?Function; + onerror: ?Function; upload: ?{ onprogress?: (event: Object) => void; @@ -79,6 +81,7 @@ class XMLHttpRequestBase { _responseType: ResponseType; _sent: boolean; _url: ?string; + _timedOut: boolean; constructor() { this.UNSENT = UNSENT; @@ -91,11 +94,15 @@ class XMLHttpRequestBase { this.onload = null; this.upload = undefined; /* Upload not supported yet */ this.timeout = 0; + this.ontimeout = null; + this.onerror = null; this._reset(); this._method = null; this._url = null; this._aborted = false; + this._timedOut = false; + this._hasError = false; } _reset(): void { @@ -115,6 +122,7 @@ class XMLHttpRequestBase { this._lowerCaseResponseHeaders = {}; this._clearSubscriptions(); + this._timedOut = false; } // $FlowIssue #10784535 @@ -249,11 +257,14 @@ class XMLHttpRequestBase { } } - _didCompleteResponse(requestId: number, error: string): void { + _didCompleteResponse(requestId: number, error: string, timeOutError: boolean): void { if (requestId === this._requestId) { if (error) { this.responseText = error; this._hasError = true; + if (timeOutError) { + this._timedOut = true; + } } this._clearSubscriptions(); this._requestId = null; @@ -362,17 +373,25 @@ class XMLHttpRequestBase { onreadystatechange.call(this, null); } if (newState === this.DONE && !this._aborted) { - this._sendLoad(); + if (this._hasError) { + if (this._timedOut) { + this._sendEvent(this.ontimeout); + } else { + this._sendEvent(this.onerror); + } + } + else { + this._sendEvent(this.onload); + } } } - _sendLoad(): void { + _sendEvent(newEvent: ?Function): void { // TODO: workaround flow bug with nullable function checks - var onload = this.onload; - if (onload) { + if (newEvent) { // We should send an event to handler, but since we don't process that // event anywhere, let's leave it empty - onload(null); + newEvent(null); } } } diff --git a/Libraries/Network/__tests__/XMLHttpRequestBase-test.js b/Libraries/Network/__tests__/XMLHttpRequestBase-test.js new file mode 100644 index 000000000..b134b031b --- /dev/null +++ b/Libraries/Network/__tests__/XMLHttpRequestBase-test.js @@ -0,0 +1,48 @@ +'use strict'; + +jest + .autoMockOff() + .dontMock('XMLHttpRequestBase'); + +const XMLHttpRequestBase = require('XMLHttpRequestBase'); + +describe('XMLHttpRequestBase', function(){ + var xhr; + + beforeEach(() => { + xhr = new XMLHttpRequestBase(); + xhr.ontimeout = jest.fn(); + xhr.onerror = jest.fn(); + xhr.onload = jest.fn(); + xhr.didCreateRequest(1); + }); + + afterEach(() => { + xhr = null; + }); + + it('should call ontimeout function when the request times out', function(){ + xhr._didCompleteResponse(1, 'Timeout', true); + + expect(xhr.ontimeout).toBeCalledWith(null); + expect(xhr.onerror).not.toBeCalled(); + expect(xhr.onload).not.toBeCalled(); + }); + + it('should call onerror function when the request times out', function(){ + xhr._didCompleteResponse(1, 'Generic error'); + + expect(xhr.onerror).toBeCalledWith(null); + expect(xhr.ontimeout).not.toBeCalled(); + expect(xhr.onload).not.toBeCalled(); + }); + + it('should call onload function when there is no error', function(){ + xhr._didCompleteResponse(1, null); + + expect(xhr.onload).toBeCalledWith(null); + expect(xhr.onerror).not.toBeCalled(); + expect(xhr.ontimeout).not.toBeCalled(); + }); + +}); diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java index 9b388c6d5..5db80193b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java @@ -15,6 +15,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.Reader; +import java.net.SocketTimeoutException; + import java.util.List; import java.util.concurrent.TimeUnit; @@ -172,7 +174,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { Headers requestHeaders = extractHeaders(headers, data); if (requestHeaders == null) { - onRequestError(executorToken, requestId, "Unrecognized headers format"); + onRequestError(executorToken, requestId, "Unrecognized headers format", null); return; } String contentType = requestHeaders.get(CONTENT_TYPE_HEADER_NAME); @@ -186,7 +188,8 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { onRequestError( executorToken, requestId, - "Payload is set but no content-type header specified"); + "Payload is set but no content-type header specified", + null); return; } String body = data.getString(REQUEST_BODY_KEY_STRING); @@ -194,7 +197,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { if (RequestBodyUtil.isGzipEncoding(contentEncoding)) { RequestBody requestBody = RequestBodyUtil.createGzip(contentMediaType, body); if (requestBody == null) { - onRequestError(executorToken, requestId, "Failed to gzip request body"); + onRequestError(executorToken, requestId, "Failed to gzip request body", null); return; } requestBuilder.method(method, requestBody); @@ -206,14 +209,15 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { onRequestError( executorToken, requestId, - "Payload is set but no content-type header specified"); + "Payload is set but no content-type header specified", + null); return; } String uri = data.getString(REQUEST_BODY_KEY_URI); InputStream fileInputStream = RequestBodyUtil.getFileInputStream(getReactApplicationContext(), uri); if (fileInputStream == null) { - onRequestError(executorToken, requestId, "Could not retrieve file for uri " + uri); + onRequestError(executorToken, requestId, "Could not retrieve file for uri " + uri, null); return; } requestBuilder.method( @@ -242,7 +246,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { if (mShuttingDown) { return; } - onRequestError(executorToken, requestId, e.getMessage()); + onRequestError(executorToken, requestId, e.getMessage(), e); } @Override @@ -264,7 +268,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { onRequestSuccess(executorToken, requestId); } } catch (IOException e) { - onRequestError(executorToken, requestId, e.getMessage()); + onRequestError(executorToken, requestId, e.getMessage(), e); } } }); @@ -294,11 +298,15 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { getEventEmitter(ExecutorToken).emit("didReceiveNetworkData", args); } - private void onRequestError(ExecutorToken ExecutorToken, int requestId, String error) { + private void onRequestError(ExecutorToken ExecutorToken, int requestId, String error, IOException e) { WritableArray args = Arguments.createArray(); args.pushInt(requestId); args.pushString(error); + if ((e != null) && (e.getClass() == SocketTimeoutException.class)) { + args.pushBoolean(true); // last argument is a time out boolean + } + getEventEmitter(ExecutorToken).emit("didCompleteNetworkResponse", args); } @@ -385,7 +393,8 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { onRequestError( ExecutorToken, requestId, - "Missing or invalid header format for FormData part."); + "Missing or invalid header format for FormData part.", + null); return null; } MediaType partContentType = null; @@ -405,7 +414,8 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { onRequestError( ExecutorToken, requestId, - "Binary FormData part needs a content-type header."); + "Binary FormData part needs a content-type header.", + null); return null; } String fileContentUriStr = bodyPart.getString(REQUEST_BODY_KEY_URI); @@ -415,12 +425,13 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { onRequestError( ExecutorToken, requestId, - "Could not retrieve file for uri " + fileContentUriStr); + "Could not retrieve file for uri " + fileContentUriStr, + null); return null; } multipartBuilder.addPart(headers, RequestBodyUtil.create(partContentType, fileInputStream)); } else { - onRequestError(ExecutorToken, requestId, "Unrecognized FormData part."); + onRequestError(ExecutorToken, requestId, "Unrecognized FormData part.", null); } } return multipartBuilder; @@ -464,4 +475,4 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { return getReactApplicationContext() .getJSModule(ExecutorToken, DeviceEventManagerModule.RCTDeviceEventEmitter.class); } -} +} \ No newline at end of file diff --git a/docs/Network.md b/docs/Network.md index 498a0df21..d3bcf9c03 100644 --- a/docs/Network.md +++ b/docs/Network.md @@ -122,6 +122,34 @@ request.open('GET', 'https://mywebsite.com/endpoint.php'); request.send(); ``` +You can also use - + +```js +var request = new XMLHttpRequest(); + +function onLoad() { + console.log(request.status); + console.log(request.responseText); +}; + +function onTimeout() { + console.log('Timeout'); + console.log(request.responseText); +}; + +function onError() { + console.log('General network error'); + console.log(request.responseText); +}; + +request.onload = onLoad; +request.ontimeout = onTimeout; +request.onerror = onError; +request.open('GET', 'https://mywebsite.com/endpoint.php'); +request.send(); +``` + + Please follow the [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) for a complete description of the API. As a developer, you're probably not going to use XMLHttpRequest directly as its API is very tedious to work with. But the fact that it is implemented and compatible with the browser API gives you the ability to use third-party libraries such as [frisbee](https://github.com/niftylettuce/frisbee) or [axios](https://github.com/mzabriskie/axios) directly from npm.