diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 601419b1e804..b49e020e079f 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -713,18 +713,26 @@ class VirtualizedList extends StateSafePureComponent< // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make // sure we're rendering a reasonable range here. const itemCount = newProps.getItemCount(newProps.data); - if (itemCount === prevState.renderMask.numCells()) { - return prevState; - } - - let maintainVisibleContentPositionAdjustment: ?number = null; const prevFirstVisibleItemKey = prevState.firstVisibleItemKey; const minIndexForVisible = newProps.maintainVisibleContentPosition?.minIndexForVisible ?? 0; const newFirstVisibleItemKey = - newProps.getItemCount(newProps.data) > minIndexForVisible + itemCount > minIndexForVisible ? VirtualizedList._getItemKey(newProps, minIndexForVisible) : null; + // React applies queued state updaters before getDerivedStateFromProps, so + // `_updateCellsToRender` may have already sized `renderMask` for the new + // data. An unchanged count then doesn't rule out a prepend, so with + // maintainVisibleContentPosition the key must be unchanged as well. + if ( + itemCount === prevState.renderMask.numCells() && + (newProps.maintainVisibleContentPosition == null || + newFirstVisibleItemKey === prevFirstVisibleItemKey) + ) { + return prevState; + } + + let maintainVisibleContentPositionAdjustment: ?number = null; if ( newProps.maintainVisibleContentPosition != null && prevFirstVisibleItemKey != null && diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index e270af4649c9..a7a66d2034f5 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -2640,6 +2640,59 @@ it('handles maintainVisibleContentPosition', async () => { expect(component).toMatchSnapshot(); }); +// Trigger: Items prepended in the same render as a queued cells update, e.g. when a scroll event takes +// the high-priority path of `_scheduleCellsToRenderUpdate` and the app updates its data from that event. +// Expected: The prepend is handled as if it rendered on its own. +it('handles a maintainVisibleContentPosition prepend batched with a cells update', async () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + await act(() => { + component = create( + , + ); + }); + + await act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: items.length * ITEM_HEIGHT}, + }); + + performAllBatches(); + }); + + const instance = component.getInstance(); + expect(instance.state.cellsAroundViewport).toEqual({first: 0, last: 4}); + + const newItems = [...generateItems(10, items.length), ...items]; + await act(() => { + instance._updateCellsToRender(); + component.update( + , + ); + }); + + // The render window moves with the previously rendered cells, and the + // adjustment is pending until native reports the new offset. + expect(instance.state.cellsAroundViewport).toEqual({first: 10, last: 14}); + expect(instance.state.pendingScrollUpdateCount).toBe(1); + expect(instance.state.firstVisibleItemKey).toBe(20); +}); + // Trigger: Item at anchor position removed from data array. // Expected: Anchor shifts to next visible item. MVCP captures new anchor's frame, computes delta, adjusts scroll. it('handles maintainVisibleContentPosition when anchor moves before minIndexForVisible', async () => {