diff --git a/packages/virtualized-lists/Lists/VirtualizeUtils.js b/packages/virtualized-lists/Lists/VirtualizeUtils.js
index 4e5c6877fc77..f59bf3c30749 100644
--- a/packages/virtualized-lists/Lists/VirtualizeUtils.js
+++ b/packages/virtualized-lists/Lists/VirtualizeUtils.js
@@ -58,6 +58,50 @@ export function elementsThatOverlapOffsets(
return result;
}
+/**
+ * The mounted cells in `range` that cover the viewport, found from their
+ * measured frames, if the viewport starts in one of them.
+ */
+function mountedCellsInViewport(
+ visibleBegin: number,
+ visibleEnd: number,
+ range: {first: number, last: number, ...},
+ props: CellMetricProps,
+ listMetrics: ListMetricsAggregator,
+ zoomScale: number,
+): ?{first: number, last: number} {
+ const lastIndex = Math.min(range.last, props.getItemCount(props.data) - 1);
+ let left = Math.max(0, range.first);
+ let right = lastIndex;
+ while (left <= right) {
+ const mid = left + Math.floor((right - left) / 2);
+ const frame = listMetrics.getCellMetricsApprox(mid, props);
+ if (visibleBegin < frame.offset * zoomScale) {
+ right = mid - 1;
+ } else if (visibleBegin >= (frame.offset + frame.length) * zoomScale) {
+ left = mid + 1;
+ } else {
+ if (!frame.isMounted) {
+ return null;
+ }
+ let last = mid;
+ while (last < lastIndex) {
+ const next = listMetrics.getCellMetrics(last + 1, props);
+ if (
+ next == null ||
+ !next.isMounted ||
+ next.offset * zoomScale >= visibleEnd
+ ) {
+ break;
+ }
+ last++;
+ }
+ return {first: mid, last};
+ }
+ }
+ return null;
+}
+
/**
* Computes the number of elements in the `next` range that are new compared to the `prev` range.
* Handy for calculating how many new items will be rendered when the render window changes so we
@@ -156,6 +200,36 @@ export function computeWindowedRenderLimits(
listMetrics,
zoomScale,
);
+ // An unmeasured cell is estimated from the current average cell length, but
+ // a spacer keeps the size it was rendered with. Once the average changes,
+ // the estimates for the cells in a spacer can overlap the rendered cells
+ // after it, and the search can land on them instead: after a large prepend
+ // under maintainVisibleContentPosition, that unmounts the cells on screen.
+ // When the search disagrees with a mounted cell's measured frame about
+ // where the viewport starts, use the mounted cells that cover it.
+ const mounted = mountedCellsInViewport(
+ visibleBegin,
+ visibleEnd,
+ prev,
+ props,
+ listMetrics,
+ zoomScale,
+ );
+ if (
+ mounted != null &&
+ first !== mounted.first &&
+ // At a boundary the search picks the earlier cell.
+ first !== mounted.first - 1
+ ) {
+ first = mounted.first;
+ last = Math.max(last ?? mounted.last, mounted.last);
+ if (overscanFirst != null) {
+ overscanFirst = Math.min(overscanFirst, first);
+ }
+ if (overscanLast != null) {
+ overscanLast = Math.max(overscanLast, last);
+ }
+ }
overscanFirst = overscanFirst == null ? 0 : overscanFirst;
first = first == null ? Math.max(0, overscanFirst) : first;
overscanLast = overscanLast == null ? itemCount - 1 : overscanLast;
diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
index e270af4649c9..08b1e16157d8 100644
--- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
+++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
@@ -3048,6 +3048,98 @@ it('handles rapid prepends with coalesced scroll event (regression for #53542)',
).toBeGreaterThanOrEqual(0);
});
+// Trigger: A large prepend at the top of the list. The prepended cells rendered above the spacer for the others
+// are taller than average, and their layouts change the average cell length before maintainVisibleContentPosition's
+// correction arrives. The spacer keeps its rendered size, but the estimates for its cells now overlap the window.
+// Expected: The window computed from the corrected offset keeps the cells on screen rendered.
+it('keeps the cells on screen when estimates for a spacer above them overlap them', async () => {
+ const items = generateItems(100);
+ const ITEM_HEIGHT = 10;
+ const PREPENDED_ITEM_HEIGHT = 100;
+
+ let component;
+ await act(() => {
+ component = create(
+ ,
+ );
+ });
+ const instance = component.getInstance();
+ const layoutCells = (data, indices, offsetOf, height) => {
+ for (const i of indices) {
+ simulateCellLayout(component, data, i, {
+ width: 10,
+ height,
+ x: 0,
+ y: offsetOf(i),
+ });
+ }
+ };
+
+ await act(() => {
+ simulateLayout(component, {
+ viewport: {width: 10, height: 50},
+ content: {width: 10, height: items.length * ITEM_HEIGHT},
+ });
+ layoutCells(items, [0, 1, 2, 3, 4], i => i * ITEM_HEIGHT, ITEM_HEIGHT);
+ performAllBatches();
+ });
+ await act(() => {
+ layoutCells(items, [0, 1, 2, 3, 4], i => i * ITEM_HEIGHT, ITEM_HEIGHT);
+ performAllBatches();
+ });
+ expect(instance.state.cellsAroundViewport).toEqual({first: 0, last: 4});
+
+ // Prepend 20 items. Cells 0 and 1 render for the initial region, then a
+ // spacer for cells 2-19 at the average, 18 * 10 = 180, then the window.
+ const newItems = [...generateItems(20, items.length), ...items];
+ await act(() => {
+ component.update(
+ ,
+ );
+ });
+ expect(instance.state.cellsAroundViewport).toEqual({first: 20, last: 24});
+
+ // Cells 0 and 1 are 100 tall, so cell 20 (the old cell 0) is laid out at
+ // 2 * 100 + 180 = 380. Their layouts raise the average to 250 / 7, which
+ // puts the estimate for cell 10 at 357-393. Then the scroll event with the
+ // correction.
+ const offset = 2 * PREPENDED_ITEM_HEIGHT + 18 * ITEM_HEIGHT;
+ await act(() => {
+ simulateContentLayout(component, {
+ width: 10,
+ height: offset + items.length * ITEM_HEIGHT,
+ });
+ layoutCells(
+ newItems,
+ [0, 1],
+ i => i * PREPENDED_ITEM_HEIGHT,
+ PREPENDED_ITEM_HEIGHT,
+ );
+ layoutCells(
+ newItems,
+ [20, 21, 22, 23, 24],
+ i => offset + (i - 20) * ITEM_HEIGHT,
+ ITEM_HEIGHT,
+ );
+ simulateScroll(component, {x: 0, y: offset});
+ performAllBatches();
+ });
+
+ const {first, last} = instance.state.cellsAroundViewport;
+ expect(first).toBeLessThanOrEqual(20);
+ expect(last).toBeGreaterThanOrEqual(24);
+});
+
function generateItems(count, startKey = 0) {
return Array(count)
.fill()