diff --git a/Examples/UIExplorer/UIExplorerList.js b/Examples/UIExplorer/UIExplorerList.js index cd73e6d06..d3ab881ac 100644 --- a/Examples/UIExplorer/UIExplorerList.js +++ b/Examples/UIExplorer/UIExplorerList.js @@ -77,6 +77,7 @@ var APIS = [ require('./StatusBarIOSExample'), require('./TimerExample'), require('./VibrationIOSExample'), + require('./XHRExample'), ]; var ds = new ListView.DataSource({ diff --git a/Examples/UIExplorer/XHRExample.js b/Examples/UIExplorer/XHRExample.js new file mode 100644 index 000000000..c5b350c70 --- /dev/null +++ b/Examples/UIExplorer/XHRExample.js @@ -0,0 +1,131 @@ +/** + * 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-native'); +var { + ProgressViewIOS, + StyleSheet, + View, + Text, + TouchableHighlight, +} = React; + +class Downloader extends React.Component { + + xhr: XMLHttpRequest; + cancelled: boolean; + + constructor(props) { + super(props); + this.cancelled = false; + this.state = { + downloading: false, + contentSize: 1, + downloaded: 0, + }; + } + + download() { + this.xhr && this.xhr.abort(); + + var xhr = this.xhr || new XMLHttpRequest(); + xhr.onreadystatechange = () => { + if (xhr.readyState === xhr.HEADERS_RECEIVED) { + var contentSize = parseInt(xhr.getResponseHeader('Content-Length'), 10); + this.setState({ + contentSize: contentSize, + downloaded: 0, + }); + } else if (xhr.readyState === xhr.LOADING) { + this.setState({ + downloaded: xhr.responseText.length, + }); + } else if (xhr.readyState === xhr.DONE) { + this.setState({ + downloading: false, + }); + if (this.cancelled) { + this.cancelled = false; + return; + } + if (xhr.status === 200) { + alert('Download complete!'); + } else if (xhr.status !== 0) { + alert('Error: Server returned HTTP status of ' + xhr.status + ' ' + xhr.responseText); + } else { + alert('Error: ' + xhr.responseText); + } + } + }; + xhr.open('GET', 'http://www.gutenberg.org/cache/epub/100/pg100.txt'); + xhr.send(); + this.xhr = xhr; + + this.setState({downloading: true}); + } + + componentWillUnmount() { + this.cancelled = true; + this.xhr && this.xhr.abort(); + } + + render() { + var button = this.state.downloading ? ( + + + Downloading... + + + ) : ( + + + Download 5MB Text File + + + ); + + return ( + + {button} + + + ); + } +} + +exports.framework = 'React'; +exports.title = 'XMLHttpRequest'; +exports.description = 'XMLHttpRequest'; +exports.examples = [{ + title: 'File Download', + render() { + return ; + } +}]; + +var styles = StyleSheet.create({ + wrapper: { + borderRadius: 5, + marginBottom: 5, + }, + button: { + backgroundColor: '#eeeeee', + padding: 10, + }, +}); diff --git a/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js b/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js index 81978ee0c..f9fe1523d 100644 --- a/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js +++ b/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js @@ -85,7 +85,7 @@ function setUpAlert() { var alertOpts = { title: 'Alert', message: '' + text, - buttons: [{'cancel': 'Okay'}], + buttons: [{'cancel': 'OK'}], }; RCTAlertManager.alertWithArgs(alertOpts, null); }; diff --git a/Libraries/Network/RCTDataManager.m b/Libraries/Network/RCTDataManager.m index f4497a187..35400955b 100644 --- a/Libraries/Network/RCTDataManager.m +++ b/Libraries/Network/RCTDataManager.m @@ -11,10 +11,21 @@ #import "RCTAssert.h" #import "RCTConvert.h" +#import "RCTEventDispatcher.h" #import "RCTLog.h" #import "RCTUtils.h" +@interface RCTDataManager () + +@end + @implementation RCTDataManager +{ + NSURLSession *_session; + NSOperationQueue *_callbackQueue; +} + +@synthesize bridge = _bridge; RCT_EXPORT_MODULE() @@ -24,6 +35,7 @@ RCT_EXPORT_MODULE() */ RCT_EXPORT_METHOD(queryData:(NSString *)queryType withQuery:(NSDictionary *)query + sendIncrementalUpdates:(BOOL)incrementalUpdates responseSender:(RCTResponseSenderBlock)responseSender) { if ([queryType isEqualToString:@"http"]) { @@ -35,41 +47,30 @@ RCT_EXPORT_METHOD(queryData:(NSString *)queryType request.allHTTPHeaderFields = [RCTConvert NSDictionary:query[@"headers"]]; request.HTTPBody = [RCTConvert NSData:query[@"data"]]; - // Build data task - NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *connectionError) { + // Create session if one doesn't already exist + if (!_session) { + _callbackQueue = [[NSOperationQueue alloc] init]; + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + _session = [NSURLSession sessionWithConfiguration:configuration + delegate:self + delegateQueue:_callbackQueue]; + } - NSHTTPURLResponse *httpResponse = nil; - if ([response isKindOfClass:[NSHTTPURLResponse class]]) { - // Might be a local file request - httpResponse = (NSHTTPURLResponse *)response; - } - - // Build response - NSArray *responseJSON; - if (connectionError == nil) { - NSStringEncoding encoding = NSUTF8StringEncoding; - if (response.textEncodingName) { - CFStringEncoding cfEncoding = CFStringConvertIANACharSetNameToEncoding((CFStringRef)response.textEncodingName); - encoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding); + __block NSURLSessionDataTask *task; + if (incrementalUpdates) { + task = [_session dataTaskWithRequest:request]; + } else { + task = [_session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + RCTSendResponseEvent(_bridge, task); + if (!error) { + RCTSendDataEvent(_bridge, task, data); } - responseJSON = @[ - @(httpResponse.statusCode ?: 200), - httpResponse.allHeaderFields ?: @{}, - [[NSString alloc] initWithData:data encoding:encoding] ?: @"", - ]; - } else { - responseJSON = @[ - @(httpResponse.statusCode), - httpResponse.allHeaderFields ?: @{}, - connectionError.localizedDescription ?: [NSNull null], - ]; - } - - // Send response (won't be sent on same thread as caller) - responseSender(responseJSON); - - }]; + RCTSendCompletionEvent(_bridge, task, error); + }]; + } + // Build data task + responseSender(@[@(task.taskIdentifier)]); [task resume]; } else { @@ -78,4 +79,78 @@ RCT_EXPORT_METHOD(queryData:(NSString *)queryType } } +#pragma mark - URLSession delegate + +- (void)URLSession:(NSURLSession *)session + dataTask:(NSURLSessionDataTask *)task +didReceiveResponse:(NSURLResponse *)response + completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler +{ + RCTSendResponseEvent(_bridge, task); + completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession *)session + dataTask:(NSURLSessionDataTask *)task + didReceiveData:(NSData *)data +{ + RCTSendDataEvent(_bridge, task, data); +} + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error +{ + RCTSendCompletionEvent(_bridge, task, error); +} + +#pragma mark - Build responses + +static void RCTSendResponseEvent(RCTBridge *bridge, NSURLSessionTask *task) +{ + NSURLResponse *response = task.response; + NSHTTPURLResponse *httpResponse = nil; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + // Might be a local file request + httpResponse = (NSHTTPURLResponse *)response; + } + + NSArray *responseJSON = @[@(task.taskIdentifier), + @(httpResponse.statusCode ?: 200), + httpResponse.allHeaderFields ?: @{}, + ]; + + [bridge.eventDispatcher sendDeviceEventWithName:@"didReceiveNetworkResponse" + body:responseJSON]; +} + +static void RCTSendDataEvent(RCTBridge *bridge, NSURLSessionDataTask *task, NSData *data) +{ + // Get text encoding + NSURLResponse *response = task.response; + NSStringEncoding encoding = NSUTF8StringEncoding; + if (response.textEncodingName) { + CFStringEncoding cfEncoding = CFStringConvertIANACharSetNameToEncoding((CFStringRef)response.textEncodingName); + encoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding); + } + + NSString *responseText = [[NSString alloc] initWithData:data encoding:encoding]; + if (!responseText && data.length) { + RCTLogError(@"Received data was invalid."); + return; + } + + NSArray *responseJSON = @[@(task.taskIdentifier), responseText ?: @""]; + [bridge.eventDispatcher sendDeviceEventWithName:@"didReceiveNetworkData" + body:responseJSON]; +} + +static void RCTSendCompletionEvent(RCTBridge *bridge, NSURLSessionTask *task, NSError *error) +{ + NSArray *responseJSON = @[@(task.taskIdentifier), + error.localizedDescription ?: [NSNull null], + ]; + + [bridge.eventDispatcher sendDeviceEventWithName:@"didCompleteNetworkResponse" + body:responseJSON]; +} + @end diff --git a/Libraries/Network/XMLHttpRequest.ios.js b/Libraries/Network/XMLHttpRequest.ios.js index 9249047da..a54822dab 100644 --- a/Libraries/Network/XMLHttpRequest.ios.js +++ b/Libraries/Network/XMLHttpRequest.ios.js @@ -12,11 +12,73 @@ 'use strict'; var RCTDataManager = require('NativeModules').DataManager; +var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter'); var XMLHttpRequestBase = require('XMLHttpRequestBase'); class XMLHttpRequest extends XMLHttpRequestBase { + _requestId: ?number; + _subscriptions: [any]; + + constructor() { + super(); + this._requestId = null; + this._subscriptions = []; + } + + _didCreateRequest(requestId: number): void { + this._requestId = requestId; + this._subscriptions.push(RCTDeviceEventEmitter.addListener( + 'didReceiveNetworkResponse', + (args) => this._didReceiveResponse.call(this, args[0], args[1], args[2]) + )); + this._subscriptions.push(RCTDeviceEventEmitter.addListener( + 'didReceiveNetworkData', + (args) => this._didReceiveData.call(this, args[0], args[1]) + )); + this._subscriptions.push(RCTDeviceEventEmitter.addListener( + 'didCompleteNetworkResponse', + (args) => this._didCompleteResponse.apply(this, args[0], args[1]) + )); + } + + _didReceiveResponse(requestId: number, status: number, responseHeaders: ?Object): void { + if (requestId === this._requestId) { + this.status = status; + this.setResponseHeaders(responseHeaders); + this.setReadyState(this.HEADERS_RECEIVED); + } + } + + _didReceiveData(requestId: number, responseText: string): void { + if (requestId === this._requestId) { + if (!this.responseText) { + this.responseText = responseText; + } else { + this.responseText += responseText; + } + this.setReadyState(this.LOADING); + } + } + + _didCompleteResponse(requestId: number, error: string): void { + if (requestId === this._requestId) { + this.responseText = error; + this._clearSubscriptions(); + this._requestId = null; + this.setReadyState(this.DONE); + } + } + + _clearSubscriptions(): void { + for (var i = 0; i < this._subscriptions.length; i++) { + var sub = this._subscriptions[i]; + sub.remove(); + } + this._subscriptions = []; + } + sendImpl(method: ?string, url: ?string, headers: Object, data: any): void { RCTDataManager.queryData( 'http', @@ -26,11 +88,16 @@ class XMLHttpRequest extends XMLHttpRequestBase { data: data, headers: headers, }, - this.callback.bind(this) + this.onreadystatechange ? true : false, + this._didCreateRequest.bind(this) ); } abortImpl(): void { + if (this._requestId) { + this._clearSubscriptions(); + this._requestId = null; + } console.warn( 'XMLHttpRequest: abort() cancels JS callbacks ' + 'but not native HTTP request.' diff --git a/Libraries/Network/XMLHttpRequestBase.js b/Libraries/Network/XMLHttpRequestBase.js index 3570e4bf2..9d06f486a 100644 --- a/Libraries/Network/XMLHttpRequestBase.js +++ b/Libraries/Network/XMLHttpRequestBase.js @@ -1,8 +1,13 @@ /** - * Copyright 2004-present Facebook. All Rights Reserved. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. * - * @flow * @providesModule XMLHttpRequestBase + * @flow */ 'use strict'; @@ -30,6 +35,7 @@ class XMLHttpRequestBase { _headers: Object; _sent: boolean; _aborted: boolean; + _lowerCaseResponseHeaders: Object; constructor() { this.UNSENT = 0; @@ -38,46 +44,52 @@ class XMLHttpRequestBase { this.LOADING = 3; this.DONE = 4; - this.onreadystatechange = undefined; - this.upload = undefined; /* Upload not supported */ - this.readyState = this.UNSENT; - this.responseHeaders = undefined; - this.responseText = undefined; - this.status = 0; + this.onreadystatechange = null; + this.onload = null; + this.upload = undefined; /* Upload not supported yet */ + this._reset(); this._method = null; this._url = null; - this._headers = {}; - this._sent = false; this._aborted = false; } + _reset() { + this.readyState = this.UNSENT; + this.responseHeaders = undefined; + this.responseText = ''; + this.status = 0; + + this._headers = {}; + this._sent = false; + this._lowerCaseResponseHeaders = {}; + } + getAllResponseHeaders(): ?string { - if (this.responseHeaders) { - var headers = []; - for (var headerName in this.responseHeaders) { - headers.push(headerName + ': ' + this.responseHeaders[headerName]); - } - return headers.join('\n'); + if (!this.responseHeaders) { + // according to the spec, return null if no response has been received + return null; } - // according to the spec, return null <==> no response has been received - return null; + var headers = this.responseHeaders || {}; + return Object.keys(headers).map((headerName) => { + return headerName + ': ' + headers[headerName]; + }).join('\n'); } getResponseHeader(header: string): ?string { - if (this.responseHeaders) { - var value = this.responseHeaders[header.toLowerCase()]; - return value !== undefined ? value : null; - } - return null; + var value = this._lowerCaseResponseHeaders[header.toLowerCase()]; + return value !== undefined ? value : null; } setRequestHeader(header: string, value: any): void { + if (this.readyState !== this.OPENED) { + throw new Error('Request has not been opened'); + } this._headers[header.toLowerCase()] = value; } open(method: string, url: string, async: ?boolean): void { - /* Other optional arguments are not supported */ + /* Other optional arguments are not supported yet */ if (this.readyState !== this.UNSENT) { throw new Error('Cannot open, already sending'); } @@ -85,10 +97,11 @@ class XMLHttpRequestBase { // async is default throw new Error('Synchronous http requests are not supported'); } + this._reset(); this._method = method; this._url = url; this._aborted = false; - this._setReadyState(this.OPENED); + this.setReadyState(this.OPENED); } sendImpl(method: ?string, url: ?string, headers: Object, data: any): void { @@ -111,20 +124,18 @@ class XMLHttpRequestBase { } abort(): void { + this._aborted = true; this.abortImpl(); // only call onreadystatechange if there is something to abort, // below logic is per spec if (!(this.readyState === this.UNSENT || (this.readyState === this.OPENED && !this._sent) || this.readyState === this.DONE)) { - this._sent = false; - this._setReadyState(this.DONE); + this._reset(); + this.setReadyState(this.DONE); } - if (this.readyState === this.DONE) { - this._sendLoad(); - } - this.readyState = this.UNSENT; - this._aborted = true; + // Reset again after, in case modified in handler + this._reset(); } callback(status: number, responseHeaders: ?Object, responseText: string): void { @@ -132,18 +143,22 @@ class XMLHttpRequestBase { return; } this.status = status; - // Headers should be case-insensitive - var lcResponseHeaders = {}; - for (var header in responseHeaders) { - lcResponseHeaders[header.toLowerCase()] = responseHeaders[header]; - } - this.responseHeaders = lcResponseHeaders; + this.setResponseHeaders(responseHeaders); this.responseText = responseText; - this._setReadyState(this.DONE); - this._sendLoad(); + this.setReadyState(this.DONE); } - _setReadyState(newState: number): void { + setResponseHeaders(responseHeaders: ?Object): void { + this.responseHeaders = responseHeaders || null; + var headers = responseHeaders || {}; + this._lowerCaseResponseHeaders = + Object.keys(headers).reduce((lcaseHeaders, headerName) => { + lcaseHeaders[headerName.toLowerCase()] = headers[headerName]; + return headers; + }, {}); + } + + setReadyState(newState: number): void { this.readyState = newState; // TODO: workaround flow bug with nullable function checks var onreadystatechange = this.onreadystatechange; @@ -152,6 +167,9 @@ class XMLHttpRequestBase { // event anywhere, let's leave it empty onreadystatechange(null); } + if (newState === this.DONE && !this._aborted) { + this._sendLoad(); + } } _sendLoad(): void {