From c1d4a48600bbfaef81bff62aefb96563fce613ff Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 7 Oct 2026 17:47:51 +0300 Subject: [PATCH] Fix VirtualizedList unmounting the maintainVisibleContentPosition anchor before its correction A cell's layout event reaches JS before the scroll event carrying native maintainVisibleContentPosition's correction for the same commit. A cells update in between computes the render window from the old offset and the new cell metrics, so when the content above the anchor changed size by a lot, the window can skip the visible row and unmount it. Hold the window while the anchor's move is uncorrected, as for a pending prepend. Fixes #58921 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Lists/VirtualizedList.js | 55 +++++++++++++- .../Lists/__tests__/VirtualizedList-test.js | 76 +++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 601419b1e804..b51e1eeb025e 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -8,7 +8,11 @@ * @format */ -import type {CellMetricProps, ListOrientation} from './ListMetricsAggregator'; +import type { + CellMetricProps, + CellMetrics, + ListOrientation, +} from './ListMetricsAggregator'; import type {ViewToken} from './ViewabilityHelper'; import type { Item, @@ -631,7 +635,7 @@ class VirtualizedList extends StateSafePureComponent< } else { // If we have a pending scroll update, we should not adjust the render window as it // might override the correct window. - if (pendingScrollUpdateCount > 0) { + if (pendingScrollUpdateCount > 0 || this._pendingAnchorCorrection) { return cellsAroundViewport.last >= getItemCount(data) ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) : cellsAroundViewport; @@ -1242,6 +1246,10 @@ class VirtualizedList extends StateSafePureComponent< _nestedChildLists: ChildListCollection = new ChildListCollection(); _offsetFromParentVirtualizedList: number = 0; + // Set when the cell maintainVisibleContentPosition is anchored on moves, until + // the scroll event with native's correction for it. Not state, so that cells + // updates queued by earlier layout events in the same batch see it too. + _pendingAnchorCorrection: boolean = false; _pendingViewabilityUpdate: boolean = false; _prevParentOffset: number = 0; _scrollMetrics: { @@ -1328,6 +1336,8 @@ class VirtualizedList extends StateSafePureComponent< cellKey: string, cellIndex: number, ): void => { + const anchorMetrics = + this._getMaintainVisibleContentPositionAnchor(cellIndex); const layoutHasChanged = this._listMetrics.notifyCellLayout({ cellIndex, cellKey, @@ -1336,6 +1346,18 @@ class VirtualizedList extends StateSafePureComponent< }); if (layoutHasChanged) { + if ( + anchorMetrics != null && + this._listMetrics.getCellMetrics(cellIndex, this.props)?.offset !== + anchorMetrics.offset + ) { + // Native maintainVisibleContentPosition shifts the scroll offset by as + // much as this cell moved, but the scroll event reporting that arrives + // after this layout event. Until then the scroll offset is stale + // relative to the cell metrics, and a window computed from the two is + // off by the shift. + this._pendingAnchorCorrection = true; + } this._scheduleCellsToRenderUpdate(); } @@ -1344,6 +1366,34 @@ class VirtualizedList extends StateSafePureComponent< this._updateViewableItems(this.props, this.state.cellsAroundViewport); }; + /** + * The metrics of the cell at `cellIndex` if native + * `maintainVisibleContentPosition` is anchored on it: a mounted cell laid out + * across the start of the viewport. + */ + _getMaintainVisibleContentPositionAnchor(cellIndex: number): ?CellMetrics { + const {data, getItemCount, getItemLayout, maintainVisibleContentPosition} = + this.props; + if ( + maintainVisibleContentPosition == null || + getItemLayout != null || + // Native picks its anchor by physical position, not flow-relative, so in + // a horizontal RTL list it isn't this cell. + this._isHorizontalRTL() || + cellIndex >= getItemCount(data) + ) { + return null; + } + const metrics = this._listMetrics.getCellMetrics(cellIndex, this.props); + const {offset} = this._scrollMetrics; + return metrics != null && + metrics.isMounted && + metrics.offset <= offset && + offset < metrics.offset + metrics.length + ? metrics + : null; + } + _onCellFocusCapture = (cellKey: string) => { this._lastFocusedCellKey = cellKey; if (ReactNativeFeatureFlags.deferFlatListFocusChangeRenderUpdate()) { @@ -1783,6 +1833,7 @@ class VirtualizedList extends StateSafePureComponent< visibleLength, zoomScale, }; + this._pendingAnchorCorrection = false; if (this.state.pendingScrollUpdateCount > 0) { this.setState<'pendingScrollUpdateCount'>({pendingScrollUpdateCount: 0}); } diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index e270af4649c9..af4613dbd38a 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -3048,6 +3048,82 @@ it('handles rapid prepends with coalesced scroll event (regression for #53542)', ).toBeGreaterThanOrEqual(0); }); +// Trigger: Content above the cell at the start of the viewport changes size, e.g. cells mounted above it are +// taller than estimated. Native MVCP moves the scroll offset by the same amount, but the layout events reach +// JS before the scroll event, and a cells update runs in between. +// Expected: The render window waits for the corrected offset and keeps the anchor cell rendered. +it('waits for the maintainVisibleContentPosition correction when the anchor cell moves', async () => { + const items = generateItems(40); + const ITEM_HEIGHT = 10; + + let component; + await act(() => { + component = create( + , + ); + }); + const instance = component.getInstance(); + // Lays out the rendered cells: cell 0 (kept for scroll-to-top) and the + // render window, `shift` further down than their index says. + const layoutRenderedCells = (shift = 0) => { + const {first, last} = instance.state.cellsAroundViewport; + const indices = [0]; + for (let i = Math.max(1, first); i <= last; i++) { + indices.push(i); + } + for (const i of indices) { + simulateCellLayout(component, items, i, { + width: 10, + height: ITEM_HEIGHT, + x: 0, + y: i * ITEM_HEIGHT + shift, + }); + } + }; + + await act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: items.length * ITEM_HEIGHT}, + }); + layoutRenderedCells(); + simulateScroll(component, {x: 0, y: 205}); + performAllBatches(); + }); + await act(() => { + layoutRenderedCells(); + performAllBatches(); + }); + + // Cell 20 is laid out across the start of the viewport. + expect(instance.state.cellsAroundViewport).toEqual({first: 20, last: 25}); + + // Content above the cells grows by 500 (e.g. a header), moving cell 20 down + // by 500. A cells update runs before the scroll event with native's + // correction. + await act(() => { + layoutRenderedCells(500); + performAllBatches(); + }); + + // The offset (205) is now 495 above cell 20. The window waits for the + // correction rather than moving there and unmounting cell 20. + expect(instance.state.cellsAroundViewport).toEqual({first: 20, last: 25}); + + await act(() => { + simulateScroll(component, {x: 0, y: 705}); + performAllBatches(); + }); + + expect(instance.state.cellsAroundViewport.first).toBeLessThanOrEqual(20); + expect(instance.state.cellsAroundViewport.last).toBeGreaterThanOrEqual(20); +}); + function generateItems(count, startKey = 0) { return Array(count) .fill()