diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 5ef712ca32f87a..42e3ce898a49e2 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -1708,12 +1708,13 @@ class VirtualizedList extends React.PureComponent { } this.setState(state => { let newState; + const {contentLength, offset, visibleLength} = this._scrollMetrics; if (!isVirtualizationDisabled) { // If we run this with bogus data, we'll force-render window {first: 0, last: 0}, // and wipe out the initialNumToRender rendered elements. // So let's wait until the scroll view metrics have been set up. And until then, // we will trust the initialNumToRender suggestion - if (this._scrollMetrics.visibleLength) { + if (visibleLength > 0 && contentLength > 0) { // If we have a non-zero initialScrollIndex and run this before we've scrolled, // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. // So let's wait until we've scrolled the view to the right place. And until then, @@ -1728,7 +1729,6 @@ class VirtualizedList extends React.PureComponent { } } } else { - const {contentLength, offset, visibleLength} = this._scrollMetrics; const distanceFromEnd = contentLength - visibleLength - offset; const renderAhead = /* $FlowFixMe(>=0.63.0 site=react_native_fb) This comment suppresses @@ -1772,6 +1772,13 @@ class VirtualizedList extends React.PureComponent { } } } + if ( + newState != null && + newState.first === state.first && + newState.last === state.last + ) { + newState = null; + } return newState; }); }; diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js index 5cee6bca2cbcdc..b462e19d1164d5 100644 --- a/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -69,11 +69,12 @@ describe('VirtualizedList', () => { it('throws if no renderItem or ListItemComponent', () => { // Silence the React error boundary warning; we expect an uncaught error. + const consoleError = console.error; jest.spyOn(console, 'error').mockImplementation(message => { if (message.startsWith('The above error occured in the ')) { return; } - console.errorDebug(message); + consoleError(message); }); const componentFactory = () => @@ -334,4 +335,53 @@ describe('VirtualizedList', () => { expect(scrollRef.measureLayout).toBeInstanceOf(jest.fn().constructor); expect(scrollRef.measureInWindow).toBeInstanceOf(jest.fn().constructor); }); + it("does not call onEndReached when it shouldn't", () => { + const ITEM_HEIGHT = 40; + const layout = {width: 300, height: 600}; + let data = Array(20) + .fill() + .map((_, key) => ({key: String(key)})); + const onEndReached = jest.fn(); + const props = { + data, + initialNumToRender: 10, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + onEndReached, + }; + + const component = ReactTestRenderer.create(); + + const instance = component.getInstance(); + + instance._onLayout({nativeEvent: {layout}}); + + // We want to test the unusual case of onContentSizeChange firing after + // onLayout, which can cause https://github.com/facebook/react-native/issues/16067 + instance._onContentSizeChange(300, props.initialNumToRender * ITEM_HEIGHT); + instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); + jest.runAllTimers(); + + expect(onEndReached).not.toHaveBeenCalled(); + + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: 700, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + jest.runAllTimers(); + + expect(onEndReached).toHaveBeenCalled(); + }); });