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