<Incremental> for incremental rendering

Summary:Everything wrapped in `<Incremental>` is rendered sequentially via `InteractionManager`.
The `onDone` callback is called when all descendent incremental components have
finished rendering, used by `<IncrementalPresenter>` to make the story visible all at once
instead of the parts popping in randomly.

This includes an example that demonstrates streaming rendering and the use of
`<IncrementalPresenter>`.  Pressing down pauses rendering and you can see the
`TouchableOpacity` animation runs smoothly.  Video:

https://youtu.be/4UNf4-8orQ4

Ideally this will be baked into React Core at some point, but according to jordwalke that's
going to require a major refactoring and take a long time, so going with this for now.

Reviewed By: ericvicenti

Differential Revision: D2506522

fb-gh-sync-id: 5969bf248de10d38b0ac22f34d7d49bf1b3ac4b6
shipit-source-id: 5969bf248de10d38b0ac22f34d7d49bf1b3ac4b6
This commit is contained in:
Spencer Ahrens 2016-03-10 08:12:34 -08:00 committed by Facebook Github Bot 2
parent dcfa620628
commit f21da3aa31
5 changed files with 629 additions and 0 deletions

View File

@ -0,0 +1,171 @@
/**
* 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.
*
* @providesModule Incremental
* @flow
*/
'use strict';
const InteractionManager = require('InteractionManager');
const React = require('React');
const DEBUG = false;
/**
* WARNING: EXPERIMENTAL. Breaking changes will probably happen a lot and will
* not be reliably announced. The whole thing might be deleted, who knows? Use
* at your own risk.
*
* React Native helps make apps smooth by doing all the heavy lifting off the
* main thread, in JavaScript. That works great a lot of the time, except that
* heavy operations like rendering may block the JS thread from responding
* quickly to events like taps, making the app feel sluggish.
*
* `<Incremental>` solves this by slicing up rendering into chunks that are
* spread across multiple event loops. Expensive components can be sliced up
* recursively by wrapping pieces of them and their decendents in
* `<Incremental>` components. `<IncrementalGroup>` can be used to make sure
* everything in the group is rendered recursively before calling `onDone` and
* moving on to another sibling group (e.g. render one row at a time, even if
* rendering the top level row component produces more `<Incremental>` chunks).
* `<IncrementalPresenter>` is a type of `<IncrementalGroup>` that keeps it's
* children invisible and out of the layout tree until all rendering completes
* recursively. This means the group will be presented to the user as one unit,
* rather than pieces popping in sequentially.
*
* `<Incremental>` only affects initial render - `setState` and other render
* updates are unaffected.
*
* The chunks are rendered sequentially using the `InteractionManager` queue,
* which means that rendering will pause if it's interrupted by an interaction,
* such as an animation or gesture.
*
* Note there is some overhead, so you don't want to slice things up too much.
* A target of 100-200ms of total work per event loop on old/slow devices might
* be a reasonable place to start.
*
* Below is an example that will incrementally render all the parts of `Row` one
* first, then present them together, then repeat the process for `Row` two, and
* so on:
*
* render: function() {
* return (
* <ScrollView>
* {Array(10).fill().map((rowIdx) => (
* <IncrementalPresenter key={rowIdx}>
* <Row>
* {Array(20).fill().map((widgetIdx) => (
* <Incremental key={widgetIdx}>
* <SlowWidget />
* </Incremental>
* ))}
* </Row>
* </IncrementalPresenter>
* ))}
* </ScrollView>
* );
* };
*
* If SlowWidget takes 30ms to render, then without `Incremental`, this would
* block the JS thread for at least `10 * 20 * 30ms = 6000ms`, but with
* `Incremental` it will probably not block for more than 50-100ms at a time,
* allowing user interactions to take place which might even unmount this
* component, saving us from ever doing the remaining rendering work.
*/
export type Props = {
/**
* Called when all the decendents have finished rendering and mounting
* recursively.
*/
onDone?: () => void;
/**
* Tags instances and associated tasks for easier debugging.
*/
name: string;
children: any;
disabled: boolean;
};
class Incremental extends React.Component {
props: Props;
state: {
doIncrementalRender: boolean;
};
context: Context;
_incrementId: number;
_mounted: boolean;
constructor(props: Props, context: Context) {
super(props, context);
this._mounted = false;
this.state = {
doIncrementalRender: false,
};
}
getName(): string {
var ctx = this.context.incrementalGroup || {};
return ctx.groupId + ':' + this._incrementId + '-' + this.props.name;
}
componentWillMount() {
var ctx = this.context.incrementalGroup;
if (!ctx) {
return;
}
this._incrementId = ++(ctx.incrementalCount);
InteractionManager.runAfterInteractions({
name: 'Incremental:' + this.getName(),
gen: () => new Promise(resolve => {
if (!this._mounted) {
resolve();
return;
}
DEBUG && console.log('set doIncrementalRender for ' + this.getName());
this.setState({doIncrementalRender: true}, resolve);
}),
}).then(() => {
this._mounted && this.props.onDone && this.props.onDone();
});
}
render(): ?ReactElement {
if (this.props.disabled || !this.context.incrementalGroupEnabled || this.state.doIncrementalRender) {
DEBUG && console.log('render ' + this.getName());
return this.props.children;
}
return null;
}
componentDidMount() {
this._mounted = true;
if (!this.context.incrementalGroup) {
this.props.onDone && this.props.onDone();
}
}
componentWillUnmount() {
this._mounted = false;
}
}
Incremental.defaultProps = {
name: '',
};
Incremental.contextTypes = {
incrementalGroup: React.PropTypes.object,
incrementalGroupEnabled: React.PropTypes.bool,
};
export type Context = {
incrementalGroupEnabled: boolean;
incrementalGroup: ?{
groupId: string;
incrementalCount: number;
};
};
module.exports = Incremental;

View File

@ -0,0 +1,197 @@
/**
* 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.
*
* @providesModule IncrementalExample
* @flow
*/
'use strict';
const React = require('react-native');
const {
InteractionManager,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} = React;
const Incremental = require('Incremental');
const IncrementalGroup = require('IncrementalGroup');
const IncrementalPresenter = require('IncrementalPresenter');
const JSEventLoopWatchdog = require('JSEventLoopWatchdog');
const StaticContainer = require('StaticContainer.react');
const performanceNow = require('performanceNow');
InteractionManager.setDeadline(1000);
JSEventLoopWatchdog.install({thresholdMS: 200});
const NUM_ITEMS = 20;
let totalWidgets = 0;
class SlowWidget extends React.Component {
state: {ctorTimestamp: number, timeToMount: number};
constructor(props, context) {
super(props, context);
this.state = {
ctorTimestamp: performanceNow(),
timeToMount: 0,
};
}
render() {
this.state.timeToMount === 0 && burnCPU(20);
return (
<View style={styles.widgetContainer}>
<Text style={styles.widgetText}>
{`${this.state.timeToMount || '?'} ms`}
</Text>
</View>
);
}
componentDidMount() {
const timeToMount = performanceNow() - this.state.ctorTimestamp;
this.setState({timeToMount});
totalWidgets++;
}
}
let imHandle;
function startInteraction() {
imHandle = InteractionManager.createInteractionHandle();
}
function stopInteraction() {
InteractionManager.clearInteractionHandle(imHandle);
}
function Block(props: Object) {
const IncrementalContainer = props.stream ? IncrementalGroup : IncrementalPresenter;
return (
<IncrementalContainer name={'b_' + props.idx}>
<TouchableOpacity
onPressIn={startInteraction}
onPressOut={stopInteraction}>
<View style={styles.block}>
<Text>
{props.idx + ': ' + (props.stream ? 'Streaming' : 'Presented')}
</Text>
{props.children}
</View>
</TouchableOpacity>
</IncrementalContainer>
);
}
const Row = (props: Object) => <View style={styles.row} {...props} />;
class IncrementalExample extends React.Component {
static title = '<Incremental*>';
static description = 'Enables incremental rendering of complex components.';
start: number;
state: {stats: ?Object};
constructor(props: mixed, context: mixed) {
super(props, context);
this.start = performanceNow();
this.state = {
stats: null,
};
(this: any)._onDone = this._onDone.bind(this);
}
_onDone() {
const onDoneElapsed = performanceNow() - this.start;
setTimeout(() => {
const stats = {
onDoneElapsed,
totalWidgets,
...JSEventLoopWatchdog.getStats(),
setTimeoutElapsed: performanceNow() - this.start,
};
stats.avgStall = stats.totalStallTime / stats.stallCount;
this.setState({stats});
console.log('onDone:', stats);
}, 0);
}
render(): ReactElement {
return (
<IncrementalGroup
disabled={false}
name="root"
onDone={this._onDone}>
<ScrollView style={styles.scrollView}>
<Text style={styles.headerText}>
Press and hold on a row to pause rendering.
</Text>
{this.state.stats && <Text>
Finished: {JSON.stringify(this.state.stats, null, 2)}
</Text>}
{Array(8).fill().map((_, blockIdx) => {
return (
<Block key={blockIdx} idx={blockIdx} stream={blockIdx < 2}>
{Array(4).fill().map((_b, rowIdx) => (
<Row key={rowIdx}>
{Array(14).fill().map((_c, widgetIdx) => (
<Incremental key={widgetIdx} name={'w_' + widgetIdx}>
<SlowWidget idx={widgetIdx} />
</Incremental>
))}
</Row>
))}
</Block>
);
})}
</ScrollView>
</IncrementalGroup>
);
}
}
function burnCPU(milliseconds) {
const start = performanceNow();
while (performanceNow() < (start + milliseconds)) {}
}
var styles = StyleSheet.create({
scrollView: {
margin: 10,
backgroundColor: 'white',
flex: 1,
},
headerText: {
fontSize: 20,
margin: 10,
},
block: {
borderRadius: 6,
borderWidth: 2,
borderColor: '#a52a2a',
padding: 14,
margin: 5,
backgroundColor: 'white',
},
row: {
flexDirection: 'row',
},
widgetContainer: {
backgroundColor: '#dddddd',
padding: 2,
margin: 2,
},
widgetText: {
color: 'black',
fontSize: 4,
},
});
module.exports = IncrementalExample;

View File

@ -0,0 +1,81 @@
/**
* 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.
*
* @providesModule IncrementalGroup
* @flow
*/
'use strict';
const Incremental = require('Incremental');
const React = require('React');
let _groupCounter = -1;
const DEBUG = false;
import type {Props, Context} from 'Incremental';
/**
* WARNING: EXPERIMENTAL. Breaking changes will probably happen a lot and will
* not be reliably announced. The whole thing might be deleted, who knows? Use
* at your own risk.
*
* `<Incremental>` components must be wrapped in an `<IncrementalGroup>` (e.g.
* via `<IncrementalPresenter>`) in order to provide the incremental group
* context, otherwise they will do nothing.
*
* See Incremental.js for more info.
*/
class IncrementalGroup extends React.Component {
props: Props;
context: Context;
_groupInc: string;
componentWillMount() {
this._groupInc = `g${++_groupCounter}-`;
DEBUG && console.log(
'create IncrementalGroup with id ' + this.getGroupId()
);
}
getGroupId(): string {
const ctx = this.context.incrementalGroup;
const prefix = ctx ? ctx.groupId + ':' : '';
return prefix + this._groupInc + this.props.name;
}
getChildContext(): Context {
if (this.props.disabled || this.context.incrementalGroupEnabled === false) {
return {
incrementalGroupEnabled: false,
incrementalGroup: null,
};
}
return {
incrementalGroupEnabled: true,
incrementalGroup: {
groupId: this.getGroupId(),
incrementalCount: -1,
},
};
}
render(): ReactElement {
return (
<Incremental
onDone={this.props.onDone}
children={this.props.children}
/>
);
}
}
IncrementalGroup.contextTypes = {
incrementalGroup: React.PropTypes.object,
incrementalGroupEnabled: React.PropTypes.bool,
};
IncrementalGroup.childContextTypes = IncrementalGroup.contextTypes;
module.exports = IncrementalGroup;

View File

@ -0,0 +1,95 @@
/**
* 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.
*
* @providesModule IncrementalPresenter
* @flow
*/
'use strict';
const IncrementalGroup = require('IncrementalGroup');
const React = require('React');
const View = require('View');
import type {Context} from 'Incremental';
/**
* WARNING: EXPERIMENTAL. Breaking changes will probably happen a lot and will
* not be reliably announced. The whole thing might be deleted, who knows? Use
* at your own risk.
*
* `<IncrementalPresenter>` can be used to group sets of `<Incremental>` renders
* such that they are initially invisible and removed from layout until all
* decendents have finished rendering, at which point they are drawn all at once
* so the UI doesn't jump around during the incremental rendering process.
*
* See Incremental.js for more info.
*/
type Props = {
name: string;
disabled: boolean;
onDone: () => void;
onLayout: (event: Object) => void;
style: mixed;
children: any;
}
class IncrementalPresenter extends React.Component {
props: Props;
context: Context;
_isDone: boolean;
constructor(props: Props, context: Context) {
super(props, context);
this._isDone = false;
(this: any).onDone = this.onDone.bind(this);
}
onDone() {
this._isDone = true;
if (this.props.disabled !== true &&
this.context.incrementalGroupEnabled !== false) {
// Avoid expensive re-renders and use setNativeProps
this.refs.view.setNativeProps(
{style: [this.props.style, {opacity: 1, position: 'relative'}]}
);
}
this.props.onDone && this.props.onDone();
}
render() {
if (this.props.disabled !== true &&
this.context.incrementalGroupEnabled !== false &&
!this._isDone) {
var style = [this.props.style, {opacity: 0, position: 'absolute'}];
} else {
var style = this.props.style;
}
return (
<IncrementalGroup
onDone={this.onDone}
name={this.props.name}
disabled={this.props.disabled}>
<View
children={this.props.children}
ref="view"
style={style}
onLayout={this.props.onLayout}
/>
</IncrementalGroup>
);
}
}
IncrementalPresenter.propTypes = {
name: React.PropTypes.string,
disabled: React.PropTypes.bool,
onDone: React.PropTypes.func,
onLayout: React.PropTypes.func,
style: View.propTypes.style,
};
IncrementalPresenter.contextTypes = {
incrementalGroup: React.PropTypes.object,
incrementalGroupEnabled: React.PropTypes.bool,
};
module.exports = IncrementalPresenter;

View File

@ -0,0 +1,85 @@
/**
* 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.
*
* @providesModule JSEventLoopWatchdog
* @flow
*/
'use strict';
const performanceNow = require('performanceNow');
type Handler = {
onIterate?: () => void;
onStall: (params: {lastInterval: number}) => string;
};
/**
* A utility for tracking stalls in the JS event loop that prevent timers and
* other events from being processed in a timely manner.
*
* The "stall" time is defined as the amount of time in access of the acceptable
* threshold, which is typically around 100-200ms. So if the treshold is set to
* 100 and a timer fires 150 ms later than it was scheduled because the event
* loop was tied up, that would be considered a 50ms stall.
*
* By default, logs stall events to the console when installed. Can also be
* queried with `getStats`.
*/
const JSEventLoopWatchdog = {
getStats: function(): Object {
return {stallCount, totalStallTime, longestStall, acceptableBusyTime};
},
reset: function() {
console.log('JSEventLoopWatchdog: reset');
totalStallTime = 0;
stallCount = 0;
longestStall = 0;
},
addHandler: function(handler: Handler) {
handlers.push(handler);
},
install: function({thresholdMS}: {thresholdMS: number}) {
acceptableBusyTime = thresholdMS;
if (installed) {
return;
}
installed = true;
let lastInterval = performanceNow();
function iteration() {
const now = performanceNow();
const busyTime = now - lastInterval;
if (busyTime >= thresholdMS) {
const stallTime = busyTime - thresholdMS;
stallCount++;
totalStallTime += stallTime;
longestStall = Math.max(longestStall, stallTime);
let msg = `JSEventLoopWatchdog: JS thread busy for ${busyTime}ms. ` +
`${totalStallTime}ms in ${stallCount} stalls so far. `;
handlers.forEach((handler) => {
msg += handler.onStall({lastInterval});
});
console.log(msg);
}
handlers.forEach((handler) => {
handler.onIterate && handler.onIterate();
});
lastInterval = now;
setTimeout(iteration, thresholdMS / 5);
}
iteration();
},
};
let acceptableBusyTime = 0;
let installed = false;
let totalStallTime = 0;
let stallCount = 0;
let longestStall = 0;
const handlers: Array<Handler> = [];
module.exports = JSEventLoopWatchdog;