Skip to content

Commit 6fb1495

Browse files
sahrensFacebook Github Bot 1
authored andcommitted
Use Batchinator in WindowedListView
Summary: Cleans things up and also defers rendering rows if there is an interaction happening. Reviewed By: achen1 Differential Revision: D3817231 fbshipit-source-id: fd08d0ca7cb6c203178f27bfc5a0f55469135c3a
1 parent 322c160 commit 6fb1495

File tree

1 file changed

+100
-81
lines changed

1 file changed

+100
-81
lines changed

Libraries/Experimental/WindowedListView.js

Lines changed: 100 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
*/
3333
'use strict';
3434

35+
const Batchinator = require('Batchinator');
3536
const IncrementalGroup = require('IncrementalGroup');
3637
const React = require('React');
3738
const ScrollView = require('ScrollView');
@@ -149,13 +150,14 @@ type Props = {
149150
*/
150151
recomputeRowsBatchingPeriod: number,
151152
/**
152-
* Called when rows will be mounted/unmounted. Mounted rows always form a contiguous block so it is expressed as a
153-
* range of start plus count.
153+
* Called when rows will be mounted/unmounted. Mounted rows always form a contiguous block so it
154+
* is expressed as a range of start plus count.
154155
*/
155156
onMountedRowsWillChange?: (firstIdx: number, count: number) => void,
156157
/**
157-
* Change this when you want to make sure the WindowedListView will re-render, for example when the result of
158-
* `renderScrollComponent` might change. It will be compared in `shouldComponentUpdate`.
158+
* Change this when you want to make sure the WindowedListView will re-render, for example when
159+
* the result of `renderScrollComponent` might change. It will be compared in
160+
* `shouldComponentUpdate`.
159161
*/
160162
shouldUpdateToken?: string,
161163
};
@@ -168,6 +170,11 @@ type State = {
168170
class WindowedListView extends React.Component {
169171
props: Props;
170172
state: State;
173+
/**
174+
* Recomputing which rows to render is batched up and run asynchronously to avoid wastful updates,
175+
* e.g. from multiple layout updates in rapid succession.
176+
*/
177+
_computeRowsToRenderBatcher: Batchinator;
171178
_firstVisible: number = -1;
172179
_lastVisible: number = -1;
173180
_scrollOffsetY: number = 0;
@@ -178,8 +185,6 @@ class WindowedListView extends React.Component {
178185
_rowFramesDirty: boolean = false;
179186
_hasCalledOnEndReached: boolean = false;
180187
_willComputeRowsToRender: boolean = false;
181-
_timeoutHandle: number = 0;
182-
_incrementPending: boolean = false;
183188
_viewableRows: Array<number> = [];
184189
_cellsInProgress: Set<string> = new Set();
185190
_scrollRef: ?Object;
@@ -191,7 +196,7 @@ class WindowedListView extends React.Component {
191196
viewablePercentThreshold: 50,
192197
renderScrollComponent: (props) => <ScrollView {...props} />,
193198
disableIncrementalRendering: false,
194-
recomputeRowsBatchingPeriod: 10, // This should capture most events that will happen in one frame
199+
recomputeRowsBatchingPeriod: 10, // This should capture most events that happen within a frame
195200
};
196201

197202
constructor(props: Props) {
@@ -200,6 +205,10 @@ class WindowedListView extends React.Component {
200205
this.props.numToRenderAhead < this.props.maxNumToRender,
201206
'WindowedListView: numToRenderAhead must be less than maxNumToRender'
202207
);
208+
this._computeRowsToRenderBatcher = new Batchinator(
209+
() => this._computeRowsToRender(this.props),
210+
this.props.recomputeRowsBatchingPeriod,
211+
);
203212
this.state = {
204213
firstRow: 0,
205214
lastRow: Math.min(this.props.data.length, this.props.initialNumToRender) - 1,
@@ -225,21 +234,27 @@ class WindowedListView extends React.Component {
225234
const newDataSubset = newProps.data.slice(newState.firstRow, newState.lastRow + 1);
226235
const prevDataSubset = this.props.data.slice(this.state.firstRow, this.state.lastRow + 1);
227236
if (newDataSubset.length !== prevDataSubset.length) {
228-
DEBUG && infoLog(' yes, subset length: ', {newLen: newDataSubset.length, oldLen: prevDataSubset.length});
237+
DEBUG && infoLog(
238+
' yes, subset length: ',
239+
{newLen: newDataSubset.length, oldLen: prevDataSubset.length}
240+
);
229241
return true;
230242
}
231243
for (let idx = 0; idx < newDataSubset.length; idx++) {
232244
if (newDataSubset[idx].rowData !== prevDataSubset[idx].rowData ||
233245
newDataSubset[idx].rowKey !== prevDataSubset[idx].rowKey) {
234-
DEBUG && infoLog(' yes, data change: ', {idx, new: newDataSubset[idx], old: prevDataSubset[idx]});
246+
DEBUG && infoLog(
247+
' yes, data change: ',
248+
{idx, new: newDataSubset[idx], old: prevDataSubset[idx]}
249+
);
235250
return true;
236251
}
237252
}
238253
DEBUG && infoLog(' knope');
239254
return false;
240255
}
241256
componentWillReceiveProps() {
242-
this._enqueueComputeRowsToRender();
257+
this._computeRowsToRenderBatcher.schedule();
243258
}
244259
_onMomentumScrollEnd = (e: Object) => {
245260
this._onScroll(e);
@@ -252,7 +267,7 @@ class WindowedListView extends React.Component {
252267
// We don't want to enqueue any updates if any cells are in the middle of an incremental render,
253268
// because it would just be wasted work.
254269
if (this._cellsInProgress.size === 0) {
255-
this._enqueueComputeRowsToRender();
270+
this._computeRowsToRenderBatcher.schedule();
256271
}
257272
if (this.props.onViewableRowsChanged && Object.keys(this._rowFrames).length) {
258273
const viewableRows = ViewabilityHelper.computeViewableRows(
@@ -273,24 +288,27 @@ class WindowedListView extends React.Component {
273288
_onNewLayout = (params: {rowKey: string, layout: Object}) => {
274289
const {rowKey, layout} = params;
275290
if (DEBUG) {
276-
const layoutPrev = this._rowFrames[rowKey] || {};
291+
const prev = this._rowFrames[rowKey] || {};
277292
infoLog(
278293
'record layout for row: ',
279-
{k: rowKey, h: layout.height, y: layout.y, x: layout.x, hp: layoutPrev.height, yp: layoutPrev.y}
294+
{k: rowKey, h: layout.height, y: layout.y, x: layout.x, hp: prev.height, yp: prev.y}
280295
);
281296
if (this._rowFrames[rowKey]) {
282297
const deltaY = Math.abs(this._rowFrames[rowKey].y - layout.y);
283298
const deltaH = Math.abs(this._rowFrames[rowKey].height - layout.height);
284299
if (deltaY > 2 || deltaH > 2) {
285300
const dataEntry = this.props.data.find((datum) => datum.rowKey === rowKey);
286-
console.warn('layout jump: ', {dataEntry, prevLayout: this._rowFrames[rowKey], newLayout: layout});
301+
console.warn(
302+
'layout jump: ',
303+
{dataEntry, prevLayout: this._rowFrames[rowKey], newLayout: layout}
304+
);
287305
}
288306
}
289307
}
290308
this._rowFrames[rowKey] = {...layout, offscreenLayoutDone: true};
291309
this._rowFramesDirty = true;
292310
if (this._cellsInProgress.size === 0) {
293-
this._enqueueComputeRowsToRender();
311+
this._computeRowsToRenderBatcher.schedule();
294312
}
295313
};
296314
_onWillUnmountCell = (rowKey: string) => {
@@ -300,8 +318,8 @@ class WindowedListView extends React.Component {
300318
}
301319
};
302320
/**
303-
* This is used to keep track of cells that are in the process of rendering. If any cells are in progress, then
304-
* other updates are skipped because they will just be wasted work.
321+
* This is used to keep track of cells that are in the process of rendering. If any cells are in
322+
* progress, then other updates are skipped because they will just be wasted work.
305323
*/
306324
_onProgressChange = ({rowKey, inProgress}: {rowKey: string, inProgress: boolean}) => {
307325
if (inProgress) {
@@ -310,26 +328,8 @@ class WindowedListView extends React.Component {
310328
this._cellsInProgress.delete(rowKey);
311329
}
312330
};
313-
/**
314-
* Recomputing which rows to render is batched up and run asynchronously to avoid wastful updates, e.g. from multiple
315-
* layout updates in rapid succession.
316-
*/
317-
_enqueueComputeRowsToRender(): void {
318-
if (!this._willComputeRowsToRender) {
319-
this._willComputeRowsToRender = true; // batch up computations
320-
clearTimeout(this._timeoutHandle);
321-
this._timeoutHandle = setTimeout(
322-
() => {
323-
this._willComputeRowsToRender = false;
324-
this._incrementPending = false;
325-
this._computeRowsToRender(this.props);
326-
},
327-
this.props.recomputeRowsBatchingPeriod
328-
);
329-
}
330-
}
331331
componentWillUnmount() {
332-
clearTimeout(this._timeoutHandle);
332+
this._computeRowsToRenderBatcher.dispose();
333333
}
334334
_computeRowsToRender(props: Object): void {
335335
const totalRows = props.data.length;
@@ -367,8 +367,9 @@ class WindowedListView extends React.Component {
367367
}
368368
this._updateVisibleRows(firstVisible, lastVisible);
369369

370-
// Unfortuantely, we can't use <Incremental> to simplify our increment logic in this function because we need to
371-
// make sure that cells are rendered in the right order one at a time when scrolling back up.
370+
// Unfortuantely, we can't use <Incremental> to simplify our increment logic in this function
371+
// because we need to make sure that cells are rendered in the right order one at a time when
372+
// scrolling back up.
372373

373374
const numRendered = lastRow - this.state.firstRow + 1;
374375
// Our last row target that we will approach incrementally
@@ -378,14 +379,10 @@ class WindowedListView extends React.Component {
378379
totalRows - 1, // Don't render past the end
379380
);
380381
// Increment the last row one at a time per JS event loop
381-
if (!this._incrementPending) {
382-
if (targetLastRow > this.state.lastRow) {
383-
lastRow++;
384-
this._incrementPending = true;
385-
} else if (targetLastRow < this.state.lastRow) {
386-
lastRow--;
387-
this._incrementPending = true;
388-
}
382+
if (targetLastRow > this.state.lastRow) {
383+
lastRow++;
384+
} else if (targetLastRow < this.state.lastRow) {
385+
lastRow--;
389386
}
390387
// Once last row is set, figure out the first row
391388
const firstRow = Math.max(
@@ -415,7 +412,8 @@ class WindowedListView extends React.Component {
415412
const rowsShouldChange = firstRow !== this.state.firstRow || lastRow !== this.state.lastRow;
416413
if (this._rowFramesDirty || rowsShouldChange) {
417414
if (rowsShouldChange) {
418-
props.onMountedRowsWillChange && props.onMountedRowsWillChange(firstRow, lastRow - firstRow + 1);
415+
props.onMountedRowsWillChange &&
416+
props.onMountedRowsWillChange(firstRow, lastRow - firstRow + 1);
419417
infoLog(
420418
'WLV: row render range will change:',
421419
{firstRow, firstVis: this._firstVisible, lastVis: this._lastVisible, lastRow},
@@ -441,21 +439,27 @@ class WindowedListView extends React.Component {
441439
const rowFrames = this._rowFrames;
442440
const rows = [];
443441
let spacerHeight = 0;
444-
// Incremental rendering is a tradeoff between throughput and responsiveness. When we have plenty of buffer (say 50%
445-
// of the target), we render incrementally to keep the app responsive. If we are dangerously low on buffer (say
446-
// below 25%) we always disable incremental to try to catch up as fast as possible. In the middle, we only disable
447-
// incremental while scrolling since it's unlikely the user will try to press a button while scrolling. We also
448-
// ignore the "buffer" size when we are bumped up against the edge of the available data.
442+
// Incremental rendering is a tradeoff between throughput and responsiveness. When we have
443+
// plenty of buffer (say 50% of the target), we render incrementally to keep the app responsive.
444+
// If we are dangerously low on buffer (say below 25%) we always disable incremental to try to
445+
// catch up as fast as possible. In the middle, we only disable incremental while scrolling
446+
// since it's unlikely the user will try to press a button while scrolling. We also ignore the
447+
// "buffer" size when we are bumped up against the edge of the available data.
449448
const firstBuffer = firstRow === 0 ? Infinity : this._firstVisible - firstRow;
450-
const lastBuffer = lastRow === this.props.data.length - 1 ? Infinity : lastRow - this._lastVisible;
449+
const lastBuffer = lastRow === this.props.data.length - 1
450+
? Infinity
451+
: lastRow - this._lastVisible;
451452
const minBuffer = Math.min(firstBuffer, lastBuffer);
452453
const disableIncrementalRendering = this.props.disableIncrementalRendering ||
453454
(this._isScrolling && minBuffer < this.props.numToRenderAhead * 0.5) ||
454455
(minBuffer < this.props.numToRenderAhead * 0.25);
455456
// Render mode is sticky while the component is mounted.
456457
for (let ii = firstRow; ii <= lastRow; ii++) {
457458
const rowKey = this.props.data[ii].rowKey;
458-
if (this._rowRenderMode[rowKey] === 'sync' || (disableIncrementalRendering && this._rowRenderMode[rowKey] !== 'async')) {
459+
if (
460+
this._rowRenderMode[rowKey] === 'sync' ||
461+
(disableIncrementalRendering && this._rowRenderMode[rowKey] !== 'async')
462+
) {
459463
this._rowRenderMode[rowKey] = 'sync';
460464
} else {
461465
this._rowRenderMode[rowKey] = 'async';
@@ -466,9 +470,9 @@ class WindowedListView extends React.Component {
466470
if (!rowFrames[rowKey]) {
467471
break; // if rowFrame missing, no following ones will exist so quit early
468472
}
469-
// Look for the first row where offscreen layout is done (only true for mounted rows) or it will be rendered
470-
// synchronously and set the spacer height such that it will offset all the unmounted rows before that one using
471-
// the saved frame data.
473+
// Look for the first row where offscreen layout is done (only true for mounted rows) or it
474+
// will be rendered synchronously and set the spacer height such that it will offset all the
475+
// unmounted rows before that one using the saved frame data.
472476
if (rowFrames[rowKey].offscreenLayoutDone || this._rowRenderMode[rowKey] === 'sync') {
473477
if (ii > 0) {
474478
const prevRowKey = this.props.data[ii - 1].rowKey;
@@ -479,15 +483,19 @@ class WindowedListView extends React.Component {
479483
}
480484
}
481485
let showIndicator = false;
482-
if (spacerHeight > (this.state.boundaryIndicatorHeight || 0) && this.props.renderWindowBoundaryIndicator) {
486+
if (
487+
spacerHeight > (this.state.boundaryIndicatorHeight || 0) &&
488+
this.props.renderWindowBoundaryIndicator
489+
) {
483490
showIndicator = true;
484491
spacerHeight -= this.state.boundaryIndicatorHeight || 0;
485492
}
486493
DEBUG && infoLog('render top spacer with height ', spacerHeight);
487494
rows.push(<View key="sp-top" style={{height: spacerHeight}} />);
488495
if (this.props.renderWindowBoundaryIndicator) {
489-
// Always render it, even if removed, so that we can get the height right away and don't waste time creating/
490-
// destroying it. Should see if there is a better spinner option that is not as expensive.
496+
// Always render it, even if removed, so that we can get the height right away and don't waste
497+
// time creating/ destroying it. Should see if there is a better spinner option that is not as
498+
// expensive.
491499
rows.push(
492500
<View
493501
style={!showIndicator && styles.remove}
@@ -590,26 +598,28 @@ type CellProps = {
590598
*/
591599
asyncRowPerfEventName: ?string,
592600
/**
593-
* Initially false to indicate the cell should be rendered "offscreen" with position: absolute so that incremental
594-
* rendering doesn't cause things to jump around. Once onNewLayout is called after offscreen rendering has completed,
595-
* includeInLayout will be set true and the finished cell can be dropped into place.
601+
* Initially false to indicate the cell should be rendered "offscreen" with position: absolute so
602+
* that incremental rendering doesn't cause things to jump around. Once onNewLayout is called
603+
* after offscreen rendering has completed, includeInLayout will be set true and the finished cell
604+
* can be dropped into place.
596605
*
597-
* This is coordinated outside this component so the parent can syncronize this re-render with managing the
598-
* placeholder sizing.
606+
* This is coordinated outside this component so the parent can syncronize this re-render with
607+
* managing the placeholder sizing.
599608
*/
600609
includeInLayout: boolean,
601610
/**
602-
* Updates the parent with the latest layout. Only called when incremental rendering is done and triggers the parent
603-
* to re-render this row with includeInLayout true.
611+
* Updates the parent with the latest layout. Only called when incremental rendering is done and
612+
* triggers the parent to re-render this row with includeInLayout true.
604613
*/
605614
onNewLayout: (params: {rowKey: string, layout: Object}) => void,
606615
/**
607-
* Used to track when rendering is in progress so the parent can avoid wastedful re-renders that are just going to be
608-
* invalidated once the cell finishes.
616+
* Used to track when rendering is in progress so the parent can avoid wastedful re-renders that
617+
* are just going to be invalidated once the cell finishes.
609618
*/
610619
onProgressChange: (progress: {rowKey: string, inProgress: boolean}) => void,
611620
/**
612-
* Used to invalidate the layout so the parent knows it needs to compensate for the height in the placeholder size.
621+
* Used to invalidate the layout so the parent knows it needs to compensate for the height in the
622+
* placeholder size.
613623
*/
614624
onWillUnmount: (rowKey: string) => void,
615625
};
@@ -625,9 +635,12 @@ class CellRenderer extends React.Component {
625635
componentWillMount() {
626636
if (this.props.asyncRowPerfEventName) {
627637
this._perfUpdateID = g_perf_update_id++;
628-
this._asyncCookie = Systrace.beginAsyncEvent(this.props.asyncRowPerfEventName + this._perfUpdateID);
638+
this._asyncCookie = Systrace.beginAsyncEvent(
639+
this.props.asyncRowPerfEventName + this._perfUpdateID
640+
);
629641
// $FlowFixMe(>=0.28.0)
630-
infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_start ${this._perfUpdateID} ${Date.now()}`);
642+
infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_start ${this._perfUpdateID} ` +
643+
`${Date.now()}`);
631644
}
632645
if (this.props.includeInLayout) {
633646
this._includeInLayoutLatch = true;
@@ -650,21 +663,27 @@ class CellRenderer extends React.Component {
650663
invariant(!this._offscreenRenderDone, 'should only finish rendering once');
651664
this._offscreenRenderDone = true;
652665

653-
// If this is not called before calling onNewLayout, the number of inProgress cells will remain non-zero,
654-
// and thus the onNewLayout call will not fire the needed state change update.
666+
// If this is not called before calling onNewLayout, the number of inProgress cells will remain
667+
// non-zero, and thus the onNewLayout call will not fire the needed state change update.
655668
this.props.onProgressChange({rowKey: this.props.rowKey, inProgress: false});
656669

657-
// If an onLayout event hasn't come in yet, then we skip here and assume it will come in later. This happens
658-
// when Incremental is disabled and _onOffscreenRenderDone is called faster than layout can happen.
659-
this._lastLayout && this.props.onNewLayout({rowKey: this.props.rowKey, layout: this._lastLayout});
670+
// If an onLayout event hasn't come in yet, then we skip here and assume it will come in later.
671+
// This happens when Incremental is disabled and _onOffscreenRenderDone is called faster than
672+
// layout can happen.
673+
this._lastLayout &&
674+
this.props.onNewLayout({rowKey: this.props.rowKey, layout: this._lastLayout});
660675

661676
DEBUG && infoLog('\n >>>>> display row ' + this.props.rowIndex + '\n\n\n');
662677
if (this.props.asyncRowPerfEventName) {
663-
// Note this doesn't include the native render time but is more accurate than also including the JS render
664-
// time of anything that has been queued up.
665-
Systrace.endAsyncEvent(this.props.asyncRowPerfEventName + this._perfUpdateID, this._asyncCookie);
678+
// Note this doesn't include the native render time but is more accurate than also including
679+
// the JS render time of anything that has been queued up.
680+
Systrace.endAsyncEvent(
681+
this.props.asyncRowPerfEventName + this._perfUpdateID,
682+
this._asyncCookie
683+
);
666684
// $FlowFixMe(>=0.28.0)
667-
infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_end ${this._perfUpdateID} ${Date.now()}`);
685+
infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_end ${this._perfUpdateID} ` +
686+
`${Date.now()}`);
668687
}
669688
}
670689
_onOffscreenRenderDone = () => {

0 commit comments

Comments
 (0)