Skip to content

Commit

Permalink
Add responseType as a concept to RCTNetworking, send binary data as b…
Browse files Browse the repository at this point in the history
…ase64

In preparation for Blob support (wherein binary XHR and WebSocket responses can be retained as native data blobs on the native side and JS receives a web-like opaque Blob object), this change makes RCTNetworking aware of the responseType that JS requests. A `xhr.responseType` of `''` or `'text'` translates to a native response type of `'text'`. A `xhr.responseType` of `arraybuffer` translates to a native response type of `base64`, as we currently lack an API to transmit TypedArrays directly to JS. This is analogous to how the WebSocket module already works, and it's a lot more versatile and much less brittle than converting a JS *string* back to a TypedArray, which is what's currently going on.

Now that we don't always send text down to JS, JS consumers might still want to get progress updates about a binary download. This is what the `'progress'` event is designed for, so this change also implements that. This change also follows the XHR spec with regards to `xhr.response` and `xhr.responseText`:

- if the response type is `'text'`, `xhr.responseText` can be peeked at by the JS consumer. It will be updated periodically as the download progresses, so long as there's either an `onreadystatechange` or `onprogress` handler on the XHR.

- if the response type is not `'text'`, `xhr.responseText` can't be access and `xhr.response` remains `null` until the response is fully received. `'progress'` events containing response details (total bytes, downloaded so far) are dispatched if there's an `onprogress` handler.

Once Blobs are landed, `xhr.responseType` of `'blob'` will correspond to the same native response type, which will cause RCTNetworking to only send a blob ID down to JS, which can then create a `Blob` object from that for consumers.

**Test Plan:** This change comes with extensive changes and expansions to the XHRExamples in UIExplorer. `onprogress` handler, `onreadystatechange` handler, and `responseType = 'arraybuffer'` can be switched on independently. I tested all combinations on both iOS and Android.
  • Loading branch information
philikon committed Jun 30, 2016
1 parent c1f7aa3 commit 8eba055
Show file tree
Hide file tree
Showing 17 changed files with 723 additions and 240 deletions.
170 changes: 138 additions & 32 deletions Examples/UIExplorer/XHRExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,29 @@
var React = require('react');
var ReactNative = require('react-native');
var {
CameraRoll,
Image,
ProgressBarAndroid,
StyleSheet,
Switch,
Text,
TextInput,
TouchableHighlight,
View,
Image,
CameraRoll
} = ReactNative;

var XHRExampleHeaders = require('./XHRExampleHeaders');
var XHRExampleCookies = require('./XHRExampleCookies');
var XHRExampleFetch = require('./XHRExampleFetch');
var XHRExampleOnTimeOut = require('./XHRExampleOnTimeOut');

/**
* Convert number of bytes to MB and round to the nearest 0.1 MB.
*/
function roundKilo(value: number): number {
return Math.round(value / 1000);
}

// TODO t7093728 This is a simplified XHRExample.ios.js.
// Once we have Camera roll, Toast, Intent (for opening URLs)
// we should make this consistent with iOS.
Expand All @@ -47,53 +55,85 @@ class Downloader extends React.Component {
this.cancelled = false;
this.state = {
status: '',
contentSize: 1,
downloaded: 0,
downloading: false,

// set by onreadystatechange
contentLength: 1,
responseLength: 0,
// set by onprogress
progressTotal: 1,
progressLoaded: 0,

readystateHandler: false,
progressHandler: true,
arraybuffer: false,
};
}

download() {
this.xhr && this.xhr.abort();

var xhr = this.xhr || new XMLHttpRequest();
xhr.onreadystatechange = () => {
const onreadystatechange = () => {
if (xhr.readyState === xhr.HEADERS_RECEIVED) {
var contentSize = parseInt(xhr.getResponseHeader('Content-Length'), 10);
const contentLength = parseInt(xhr.getResponseHeader('Content-Length'), 10);
this.setState({
contentSize: contentSize,
downloaded: 0,
contentLength,
responseLength: 0,
});
} else if (xhr.readyState === xhr.LOADING) {
} else if (xhr.readyState === xhr.LOADING && xhr.response) {
this.setState({
downloaded: xhr.responseText.length,
responseLength: xhr.response.length,
});
} else if (xhr.readyState === xhr.DONE) {
if (this.cancelled) {
this.cancelled = false;
return;
}
if (xhr.status === 200) {
this.setState({
status: 'Download complete!',
});
} else if (xhr.status !== 0) {
this.setState({
status: 'Error: Server returned HTTP status of ' + xhr.status + ' ' + xhr.responseText,
});
} else {
this.setState({
status: 'Error: ' + xhr.responseText,
});
}
};
const onprogress = (event) => {
this.setState({
progressTotal: event.total,
progressLoaded: event.loaded,
});
};

if (this.state.readystateHandler) {
xhr.onreadystatechange = onreadystatechange;
}
if (this.state.progressHandler) {
xhr.onprogress = onprogress;
}
if (this.state.arraybuffer) {
xhr.responseType = 'arraybuffer';
}
xhr.onload = () => {
this.setState({downloading: false});
if (this.cancelled) {
this.cancelled = false;
return;
}
if (xhr.status === 200) {
let responseType = `Response is a string, ${xhr.response.length} characters long.`;
if (typeof ArrayBuffer !== 'undefined' &&
xhr.response instanceof ArrayBuffer) {
responseType = `Response is an ArrayBuffer, ${xhr.response.byteLength} bytes long.`;
}
this.setState({status: `Download complete! ${responseType}`});
} else if (xhr.status !== 0) {
this.setState({
status: 'Error: Server returned HTTP status of ' + xhr.status + ' ' + xhr.responseText
});
} else {
this.setState({status: 'Error: ' + xhr.responseText});
}
};
xhr.open('GET', 'http://www.gutenberg.org/cache/epub/100/pg100.txt');
xhr.open('GET', 'http://aleph.gutenberg.org/cache/epub/100/pg100.txt.utf8');
// Avoid gzip so we can actually show progress
xhr.setRequestHeader('Accept-Encoding', '');
xhr.send();
this.xhr = xhr;

this.setState({status: 'Downloading...'});
this.setState({
downloading: true,
status: 'Downloading...',
});
}

componentWillUnmount() {
Expand All @@ -102,7 +142,7 @@ class Downloader extends React.Component {
}

render() {
var button = this.state.status === 'Downloading...' ? (
var button = this.state.downloading ? (
<View style={styles.wrapper}>
<View style={styles.button}>
<Text>...</Text>
Expand All @@ -118,11 +158,67 @@ class Downloader extends React.Component {
</TouchableHighlight>
);

let readystate = null;
let progress = null;
if (this.state.readystateHandler && !this.state.arraybuffer) {
const { responseLength, contentLength } = this.state;
readystate = (
<View>
<Text style={styles.progressBarLabel}>
responseText:{' '}
{roundKilo(responseLength)}/{roundKilo(contentLength)}k chars
</Text>
<ProgressBarAndroid
progress={(responseLength / contentLength)}
styleAttr="Horizontal"
indeterminate={false}
/>
</View>
);
}
if (this.state.progressHandler) {
const { progressLoaded, progressTotal } = this.state;
progress = (
<View>
<Text style={styles.progressBarLabel}>
onprogress:{' '}
{roundKilo(progressLoaded)}/{roundKilo(progressTotal)} KB
</Text>
<ProgressBarAndroid
progress={(progressLoaded / progressTotal)}
styleAttr="Horizontal"
indeterminate={false}
/>
</View>
);
}

return (
<View>
<View style={styles.configRow}>
<Text>onreadystatechange handler</Text>
<Switch
value={this.state.readystateHandler}
onValueChange={(readystateHandler => this.setState({readystateHandler}))}
/>
</View>
<View style={styles.configRow}>
<Text>onprogress handler</Text>
<Switch
value={this.state.progressHandler}
onValueChange={(progressHandler => this.setState({progressHandler}))}
/>
</View>
<View style={styles.configRow}>
<Text>download as arraybuffer</Text>
<Switch
value={this.state.arraybuffer}
onValueChange={(arraybuffer => this.setState({arraybuffer}))}
/>
</View>
{button}
<ProgressBarAndroid progress={(this.state.downloaded / this.state.contentSize)}
styleAttr="Horizontal" indeterminate={false} />
{readystate}
{progress}
<Text>{this.state.status}</Text>
</View>
);
Expand Down Expand Up @@ -357,6 +453,16 @@ var styles = StyleSheet.create({
backgroundColor: '#eeeeee',
padding: 8,
},
progressBarLabel: {
marginTop: 12,
marginBottom: 8,
},
configRow: {
flexDirection: 'row',
paddingVertical: 8,
alignItems: 'center',
justifyContent: 'space-between',
},
paramRow: {
flexDirection: 'row',
paddingVertical: 8,
Expand Down
Loading

0 comments on commit 8eba055

Please sign in to comment.