From 557172ab091d1960f022ef25bdcfe18adaeeeef0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 7 Oct 2026 22:25:02 +0300 Subject: [PATCH] Keep VirtualizedList's on-screen cells when estimates above them overlap them computeWindowedRenderLimits finds the visible cells by binary-searching getCellMetricsApprox, which places an unmeasured cell at the current average cell length times its index. A spacer keeps the size it was rendered with, so once the average changes, the estimates for the cells in a spacer can overlap the rendered cells after it. After a large prepend at the top under maintainVisibleContentPosition, the prepended cells rendered above the spacer change the average before native's correction arrives, the corrected offset maps into the spacer, and the cells on screen are unmounted. When the search disagrees with a mounted cell's measured frame about where the viewport starts, use the mounted cells that cover the viewport. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Lists/VirtualizeUtils.js | 74 +++++++++++++++ .../Lists/__tests__/VirtualizedList-test.js | 92 +++++++++++++++++++ 2 files changed, 166 insertions(+) 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()