Skip to content

Commit aab9df3

Browse files
NickGerlemanfacebook-github-bot
authored andcommitted
Gracefully handle out-of-bounds initialScrollIndex
Summary: Changelog: [General][Fixed] - Gracefully handle out-of-bounds initialScrollIndex Reviewed By: rshest Differential Revision: D43672964 fbshipit-source-id: dbd9007c538015fc586e573d268135b7557dc908
1 parent 31b4550 commit aab9df3

4 files changed

Lines changed: 129 additions & 46 deletions

File tree

packages/virtualized-lists/Lists/VirtualizedList.js

Lines changed: 74 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
keyExtractor as defaultKeyExtractor,
5151
} from './VirtualizeUtils';
5252
import invariant from 'invariant';
53+
import nullthrows from 'nullthrows';
5354
import * as React from 'react';
5455

5556
export type {RenderItemProps, RenderItemType, Separators};
@@ -420,21 +421,7 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
420421

421422
constructor(props: Props) {
422423
super(props);
423-
invariant(
424-
// $FlowFixMe[prop-missing]
425-
!props.onScroll || !props.onScroll.__isNative,
426-
'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' +
427-
'to support native onScroll events with useNativeDriver',
428-
);
429-
invariant(
430-
windowSizeOrDefault(props.windowSize) > 0,
431-
'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.',
432-
);
433-
434-
invariant(
435-
props.getItemCount,
436-
'VirtualizedList: The "getItemCount" prop must be provided',
437-
);
424+
this.checkProps(props);
438425

439426
this._fillRateHelper = new FillRateHelper(this._getFrameMetrics);
440427
this._updateCellsToRenderBatcher = new Batchinator(
@@ -459,11 +446,6 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
459446
}
460447
}
461448

462-
invariant(
463-
!this.context,
464-
'Unexpectedly saw VirtualizedListContext available in ctor',
465-
);
466-
467449
const initialRenderRegion = VirtualizedList._initialRenderRegion(props);
468450

469451
this.state = {
@@ -472,6 +454,53 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
472454
};
473455
}
474456

457+
checkProps(props: Props) {
458+
const {onScroll, windowSize, getItemCount, data, initialScrollIndex} =
459+
props;
460+
461+
invariant(
462+
// $FlowFixMe[prop-missing]
463+
!onScroll || !onScroll.__isNative,
464+
'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' +
465+
'to support native onScroll events with useNativeDriver',
466+
);
467+
invariant(
468+
windowSizeOrDefault(windowSize) > 0,
469+
'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.',
470+
);
471+
472+
invariant(
473+
getItemCount,
474+
'VirtualizedList: The "getItemCount" prop must be provided',
475+
);
476+
477+
const itemCount = getItemCount(data);
478+
479+
if (
480+
initialScrollIndex != null &&
481+
(initialScrollIndex < 0 ||
482+
(itemCount > 0 && initialScrollIndex >= itemCount)) &&
483+
!this._hasWarned.initialScrollIndex
484+
) {
485+
console.warn(
486+
`initialScrollIndex "${initialScrollIndex}" is not valid (list has ${itemCount} items)`,
487+
);
488+
this._hasWarned.initialScrollIndex = true;
489+
}
490+
491+
if (__DEV__ && !this._hasWarned.flexWrap) {
492+
// $FlowFixMe[underconstrained-implicit-instantiation]
493+
const flatStyles = StyleSheet.flatten(this.props.contentContainerStyle);
494+
if (flatStyles != null && flatStyles.flexWrap === 'wrap') {
495+
console.warn(
496+
'`flexWrap: `wrap`` is not supported with the `VirtualizedList` components.' +
497+
'Consider using `numColumns` with `FlatList` instead.',
498+
);
499+
this._hasWarned.flexWrap = true;
500+
}
501+
}
502+
}
503+
475504
static _createRenderMask(
476505
props: Props,
477506
cellsAroundViewport: {first: number, last: number},
@@ -518,15 +547,21 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
518547

519548
static _initialRenderRegion(props: Props): {first: number, last: number} {
520549
const itemCount = props.getItemCount(props.data);
521-
const scrollIndex = Math.floor(Math.max(0, props.initialScrollIndex ?? 0));
550+
551+
const firstCellIndex = Math.max(
552+
0,
553+
Math.min(itemCount - 1, Math.floor(props.initialScrollIndex ?? 0)),
554+
);
555+
556+
const lastCellIndex =
557+
Math.min(
558+
itemCount,
559+
firstCellIndex + initialNumToRenderOrDefault(props.initialNumToRender),
560+
) - 1;
522561

523562
return {
524-
first: scrollIndex,
525-
last:
526-
Math.min(
527-
itemCount,
528-
scrollIndex + initialNumToRenderOrDefault(props.initialNumToRender),
529-
) - 1,
563+
first: firstCellIndex,
564+
last: lastCellIndex,
530565
};
531566
}
532567

@@ -807,16 +842,7 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
807842
}
808843

809844
render(): React.Node {
810-
if (__DEV__) {
811-
// $FlowFixMe[underconstrained-implicit-instantiation]
812-
const flatStyles = StyleSheet.flatten(this.props.contentContainerStyle);
813-
if (flatStyles != null && flatStyles.flexWrap === 'wrap') {
814-
console.warn(
815-
'`flexWrap: `wrap`` is not supported with the `VirtualizedList` components.' +
816-
'Consider using `numColumns` with `FlatList` instead.',
817-
);
818-
}
819-
}
845+
this.checkProps(this.props);
820846
const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} =
821847
this.props;
822848
const {data, horizontal} = this.props;
@@ -1507,10 +1533,17 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
15071533
!this._hasTriggeredInitialScrollToIndex
15081534
) {
15091535
if (this.props.contentOffset == null) {
1510-
this.scrollToIndex({
1511-
animated: false,
1512-
index: this.props.initialScrollIndex,
1513-
});
1536+
if (
1537+
this.props.initialScrollIndex <
1538+
this.props.getItemCount(this.props.data)
1539+
) {
1540+
this.scrollToIndex({
1541+
animated: false,
1542+
index: nullthrows(this.props.initialScrollIndex),
1543+
});
1544+
} else {
1545+
this.scrollToEnd({animated: false});
1546+
}
15141547
}
15151548
this._hasTriggeredInitialScrollToIndex = true;
15161549
}

packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -825,10 +825,12 @@ it('unmounts sticky headers moved below viewport', () => {
825825
expect(component).toMatchSnapshot();
826826
});
827827

828-
it('gracefully handles negaitve initialScrollIndex', () => {
828+
it('gracefully handles negative initialScrollIndex', () => {
829829
const items = generateItems(10);
830830
const ITEM_HEIGHT = 10;
831831

832+
const mockWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
833+
832834
const component = ReactTestRenderer.create(
833835
<VirtualizedList
834836
initialScrollIndex={-1}
@@ -838,9 +840,56 @@ it('gracefully handles negaitve initialScrollIndex', () => {
838840
/>,
839841
);
840842

841-
// Existing code assumes we handle this in some way. Do something reasonable
842-
// here.
843+
expect(mockWarn).toHaveBeenCalledTimes(1);
844+
845+
ReactTestRenderer.act(() => {
846+
simulateLayout(component, {
847+
viewport: {width: 10, height: 50},
848+
content: {width: 10, height: 100},
849+
});
850+
performAllBatches();
851+
});
852+
843853
expect(component).toMatchSnapshot();
854+
expect(mockWarn).toHaveBeenCalledTimes(1);
855+
mockWarn.mockRestore();
856+
});
857+
858+
it('gracefully handles too large initialScrollIndex', () => {
859+
const items = generateItems(10);
860+
const ITEM_HEIGHT = 10;
861+
862+
const listRef = React.createRef();
863+
864+
const mockWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
865+
866+
const component = ReactTestRenderer.create(
867+
<VirtualizedList
868+
ref={listRef}
869+
initialScrollIndex={15}
870+
initialNumToRender={4}
871+
{...baseItemProps(items)}
872+
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
873+
/>,
874+
);
875+
876+
expect(mockWarn).toHaveBeenCalledTimes(1);
877+
listRef.current.scrollToEnd = jest.fn();
878+
879+
ReactTestRenderer.act(() => {
880+
simulateLayout(component, {
881+
viewport: {width: 10, height: 50},
882+
content: {width: 10, height: 100},
883+
});
884+
performAllBatches();
885+
});
886+
887+
expect(mockWarn).toHaveBeenCalledTimes(1);
888+
mockWarn.mockRestore();
889+
890+
expect(listRef.current.scrollToEnd).toHaveBeenLastCalledWith({
891+
animated: false,
892+
});
844893
});
845894

846895
it('renders offset cells in initial render when initialScrollIndex set', () => {

packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3036,7 +3036,7 @@ exports[`expands render area by maxToRenderPerBatch on tick 1`] = `
30363036
</RCTScrollView>
30373037
`;
30383038

3039-
exports[`gracefully handles negaitve initialScrollIndex 1`] = `
3039+
exports[`gracefully handles negative initialScrollIndex 1`] = `
30403040
<RCTScrollView
30413041
data={
30423042
Array [

packages/virtualized-lists/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
},
1010
"license": "MIT",
1111
"dependencies": {
12-
"invariant": "^2.2.4"
12+
"invariant": "^2.2.4",
13+
"nullthrows": "^1.1.1"
1314
},
1415
"devDependencies": {
1516
"react-test-renderer": "18.2.0"

0 commit comments

Comments
 (0)