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 () => {