From 409f114d685ea76984d5d1d115ef1be7adeba339 Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Thu, 13 Aug 2026 10:47:32 -0700 Subject: [PATCH] Fix maintainVisibleContentPosition with rapid data updates (#53542) Fixes #53542 where rapid prepends with coalesced native scroll events leave pendingScrollUpdateCount stuck, freezing render window. Root cause: pendingScrollUpdateCount increments by 1 per prepend (0->1->2) but EventQueue coalesces N scroll events into 1 via dispatchUniqueEvent (ScrollViewEventEmitter.cpp:13 + EventQueue.cpp:29-50 replaces in place), so single scroll drains 2->1 leaving blocked. Also leak when JS predicate fires but native declines (tag recycled, delta<=0.5, clamped). Fix: Clamp pending to 1 (not accumulate) and drain to 0 on any scroll, so single coalesced event unblocks. Prevents frozen window, suppressed onViewableItemsChanged and onEndReached. [GENERAL] [FIXED] - Fix maintainVisibleContentPosition with rapid data updates (#53542) --- .../Lists/VirtualizedList.js | 6 +- .../Lists/__tests__/VirtualizedList-test.js | 77 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index a8593403e4cd..8545ba4c6ff8 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -777,7 +777,7 @@ class VirtualizedList extends StateSafePureComponent< firstVisibleItemKey: newFirstVisibleItemKey, pendingScrollUpdateCount: maintainVisibleContentPositionAdjustment != null - ? prevState.pendingScrollUpdateCount + 1 + ? 1 : prevState.pendingScrollUpdateCount, }; } @@ -1760,9 +1760,7 @@ class VirtualizedList extends StateSafePureComponent< zoomScale, }; if (this.state.pendingScrollUpdateCount > 0) { - this.setState<'pendingScrollUpdateCount'>(state => ({ - pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1, - })); + this.setState<'pendingScrollUpdateCount'>({pendingScrollUpdateCount: 0}); } this._updateViewableItems(this.props, this.state.cellsAroundViewport); if (!this.props) { diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index 6547145e9201..661be1b1f83c 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -2877,6 +2877,83 @@ it('maintainVisibleContentPosition with inverted VirtualizedList handles prepend expect(anchorAfterPrepend).toBeLessThanOrEqual(anchorBeforePrepend + 10); }); +it('handles rapid prepends with coalesced scroll event (regression for #53542)', 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}, + }); + simulateScroll(component, {x: 0, y: 50}); + performAllBatches(); + }); + + const afterFirstPrepend = [...generateItems(5, items.length), ...items]; + const afterSecondPrepend = [ + ...generateItems(3, afterFirstPrepend.length), + ...afterFirstPrepend, + ]; + + // Two rapid prepends WITHOUT intermediate scroll (coalesced native event) + await act(() => { + component.update( + , + ); + }); + + await act(() => { + component.update( + , + ); + }); + + // Only ONE coalesced scroll event for both prepends (delta 8*ITEM_HEIGHT) + // This simulates EventQueue coalescing: dispatchUniqueEvent replaces previous scroll + // Previously: pending 0→1→2, scroll 2→1 (still blocked). Now: 0→1→1→0 (unblocked) + await act(() => { + simulateContentLayout(component, { + width: 10, + height: afterSecondPrepend.length * ITEM_HEIGHT, + }); + simulateScroll(component, {x: 0, y: 50 + 8 * ITEM_HEIGHT}); + performAllBatches(); + }); + + // Pending should be 0, not 1 – this fails on main before fix + expect(component.getInstance().state.pendingScrollUpdateCount).toBe(0); + expect(component.getInstance().state.firstVisibleItemKey).not.toBeNull(); + expect( + component.getInstance().state.cellsAroundViewport.first, + ).toBeGreaterThanOrEqual(0); +}); + function generateItems(count, startKey = 0) { return Array(count) .fill()