From e12746ae4794e532a20a0ab64860d8316948aa3a Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Fri, 16 Oct 2026 07:40:50 +0200 Subject: [PATCH 1/2] Skip the VirtualizedList cells update when the render window is unchanged _updateCellsToRender runs on every scroll event and enqueues a state updater that returns null when the window of rendered cells is the same, which is most of the time. React still re-renders the list to find that out. Run the same comparison before calling setState, while no other state update of the list is pending and once the list has been laid out. Co-Authored-By: Claude Opus 5.5 --- .../Lists/VirtualizedList.js | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index cea36dfff968..9ac5bbbdf4b2 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -1457,6 +1457,10 @@ class VirtualizedList extends StateSafePureComponent< > = null; _parentRegistration: ?ParentRegistration = null; _offsetFromParentVirtualizedList: number = 0; + // Ids of state updates that React hasn't processed yet. While any are pending, + // `this.state` is behind the state the next updater will see. + _pendingStateUpdates: Set = new Set(); + _nextStateUpdateId: number = 0; _pendingViewabilityUpdate: boolean = false; _prevParentOffset: number = 0; _scrollMetrics: { @@ -2002,7 +2006,12 @@ class VirtualizedList extends StateSafePureComponent< zoomScale, }; if (this.state.pendingScrollUpdateCount > 0) { - this.setState<'pendingScrollUpdateCount'>({pendingScrollUpdateCount: 0}); + const updateId = this._nextStateUpdateId++; + this._pendingStateUpdates.add(updateId); + this.setState<'pendingScrollUpdateCount'>(() => { + this._pendingStateUpdates.delete(updateId); + return {pendingScrollUpdateCount: 0}; + }); } this._updateViewableItems(this.props, this.state.cellsAroundViewport); this._notifyCrossOrientationChildren(false, 0); @@ -2161,7 +2170,45 @@ class VirtualizedList extends StateSafePureComponent< this._updateViewableItems(this.props, this.state.cellsAroundViewport); this._notifyCrossOrientationChildren(); + // This runs on every scroll event, and most of the time the window of + // rendered cells doesn't change. The updater below would then return null, + // but React would still re-render the list to find that out, so do the same + // comparison here first. It only gives the same answer while no other + // update is pending (`this.state` would be behind) and once the list has + // been laid out (before that, layout callbacks arrive back to back and the + // updater would see metrics from after this call). + if ( + this._pendingStateUpdates.size === 0 && + this._scrollMetrics.visibleLength > 0 && + this._listMetrics.getContentLength() > 0 + ) { + const {cellsAroundViewport, pendingScrollUpdateCount, renderMask} = + this.state; + const nextCellsAroundViewport = this._adjustCellsAroundViewport( + this.props, + cellsAroundViewport, + pendingScrollUpdateCount, + ); + if ( + nextCellsAroundViewport.first === cellsAroundViewport.first && + nextCellsAroundViewport.last === cellsAroundViewport.last && + VirtualizedList._createRenderMask( + this.props, + nextCellsAroundViewport, + this._getNonViewportRenderRegions(this.props), + ).equals(renderMask) + ) { + // No update means no componentDidUpdate to reset this, which would + // push the next hiPri update onto the batching timer. + this._hiPriInProgress = false; + return; + } + } + + const updateId = this._nextStateUpdateId++; + this._pendingStateUpdates.add(updateId); this.setState<'cellsAroundViewport' | 'renderMask'>((state, props) => { + this._pendingStateUpdates.delete(updateId); const cellsAroundViewport = this._adjustCellsAroundViewport( props, state.cellsAroundViewport, From 2af2fd003792df9cb906aab079400360fa39117b Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Fri, 16 Oct 2026 13:47:50 +0200 Subject: [PATCH 2/2] Use a single pending-update flag and a shared cells update function Track a queued state update with one boolean instead of a set of ids, and move the computation the cells updater does into a static _computeCellsUpdate(list, props, state), which the check before setState calls too, so both evaluate the same thing for the same state. Add a test that a scroll which keeps the render window doesn't call setState. Co-Authored-By: Claude Opus 5.5 --- .../Lists/VirtualizedList.js | 113 +++++++++--------- .../Lists/__tests__/VirtualizedList-test.js | 41 +++++++ 2 files changed, 96 insertions(+), 58 deletions(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 9ac5bbbdf4b2..a0f91af4debe 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -658,6 +658,41 @@ class VirtualizedList extends StateSafePureComponent< return VirtualizedList._keyExtractor(item, index, props); } + /** + * The render window and mask to move to from `state`, or null when they + * don't change. It reads nothing from the caller, only its arguments and the + * scroll and layout metrics of `list` as they are when it runs, so the + * `_updateCellsToRender` updater and the check before it compute the same + * result for the same state. + */ + static _computeCellsUpdate( + list: VirtualizedList, + props: VirtualizedListProps, + state: State, + ): ?{ + cellsAroundViewport: {first: number, last: number}, + renderMask: CellRenderMask, + } { + const cellsAroundViewport = list._adjustCellsAroundViewport( + props, + state.cellsAroundViewport, + state.pendingScrollUpdateCount, + ); + const renderMask = VirtualizedList._createRenderMask( + props, + cellsAroundViewport, + list._getNonViewportRenderRegions(props), + ); + if ( + cellsAroundViewport.first === state.cellsAroundViewport.first && + cellsAroundViewport.last === state.cellsAroundViewport.last && + renderMask.equals(state.renderMask) + ) { + return null; + } + return {cellsAroundViewport, renderMask}; + } + static _createRenderMask( props: VirtualizedListProps, cellsAroundViewport: {first: number, last: number}, @@ -1457,10 +1492,9 @@ class VirtualizedList extends StateSafePureComponent< > = null; _parentRegistration: ?ParentRegistration = null; _offsetFromParentVirtualizedList: number = 0; - // Ids of state updates that React hasn't processed yet. While any are pending, - // `this.state` is behind the state the next updater will see. - _pendingStateUpdates: Set = new Set(); - _nextStateUpdateId: number = 0; + // Whether a state update is queued that React hasn't processed yet. While + // one is, `this.state` is behind the state the next updater will see. + _hasPendingStateUpdate: boolean = false; _pendingViewabilityUpdate: boolean = false; _prevParentOffset: number = 0; _scrollMetrics: { @@ -2006,10 +2040,9 @@ class VirtualizedList extends StateSafePureComponent< zoomScale, }; if (this.state.pendingScrollUpdateCount > 0) { - const updateId = this._nextStateUpdateId++; - this._pendingStateUpdates.add(updateId); + this._hasPendingStateUpdate = true; this.setState<'pendingScrollUpdateCount'>(() => { - this._pendingStateUpdates.delete(updateId); + this._hasPendingStateUpdate = false; return {pendingScrollUpdateCount: 0}; }); } @@ -2172,63 +2205,27 @@ class VirtualizedList extends StateSafePureComponent< // This runs on every scroll event, and most of the time the window of // rendered cells doesn't change. The updater below would then return null, - // but React would still re-render the list to find that out, so do the same - // comparison here first. It only gives the same answer while no other - // update is pending (`this.state` would be behind) and once the list has - // been laid out (before that, layout callbacks arrive back to back and the - // updater would see metrics from after this call). + // but React would still schedule and run a render pass to find that out, + // so do the same computation here first. It only gives the same answer + // while no other update is pending (`this.state` would be behind) and once + // the list has been laid out (before that, layout callbacks arrive back to + // back and the updater would see metrics from after this call). if ( - this._pendingStateUpdates.size === 0 && + !this._hasPendingStateUpdate && this._scrollMetrics.visibleLength > 0 && - this._listMetrics.getContentLength() > 0 + this._listMetrics.getContentLength() > 0 && + VirtualizedList._computeCellsUpdate(this, this.props, this.state) == null ) { - const {cellsAroundViewport, pendingScrollUpdateCount, renderMask} = - this.state; - const nextCellsAroundViewport = this._adjustCellsAroundViewport( - this.props, - cellsAroundViewport, - pendingScrollUpdateCount, - ); - if ( - nextCellsAroundViewport.first === cellsAroundViewport.first && - nextCellsAroundViewport.last === cellsAroundViewport.last && - VirtualizedList._createRenderMask( - this.props, - nextCellsAroundViewport, - this._getNonViewportRenderRegions(this.props), - ).equals(renderMask) - ) { - // No update means no componentDidUpdate to reset this, which would - // push the next hiPri update onto the batching timer. - this._hiPriInProgress = false; - return; - } + // No update means no componentDidUpdate to reset this, which would + // push the next hiPri update onto the batching timer. + this._hiPriInProgress = false; + return; } - const updateId = this._nextStateUpdateId++; - this._pendingStateUpdates.add(updateId); + this._hasPendingStateUpdate = true; this.setState<'cellsAroundViewport' | 'renderMask'>((state, props) => { - this._pendingStateUpdates.delete(updateId); - const cellsAroundViewport = this._adjustCellsAroundViewport( - props, - state.cellsAroundViewport, - state.pendingScrollUpdateCount, - ); - const renderMask = VirtualizedList._createRenderMask( - props, - cellsAroundViewport, - this._getNonViewportRenderRegions(props), - ); - - if ( - cellsAroundViewport.first === state.cellsAroundViewport.first && - cellsAroundViewport.last === state.cellsAroundViewport.last && - renderMask.equals(state.renderMask) - ) { - return null; - } - - return {cellsAroundViewport, renderMask}; + this._hasPendingStateUpdate = false; + return VirtualizedList._computeCellsUpdate(this, props, state); }); }; diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index e270af4649c9..d3f61d667768 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -2275,6 +2275,47 @@ it('renders windowSize derived region at top', async () => { expect(component).toMatchSnapshot(); }); +it('does not update state on a scroll that keeps the render window', async () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + await act(() => { + component = create( + , + ); + }); + + await act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 20}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + await act(() => { + jest.runAllTimers(); + }); + + const setStateSpy = jest.spyOn(component.getInstance(), 'setState'); + + // The window already covers the viewport at the top and the 20 dip below + // it, so a scroll back to the top doesn't change it. + await act(() => { + simulateScroll(component, {x: 0, y: 0}); + jest.runAllTimers(); + }); + + expect(setStateSpy).not.toHaveBeenCalled(); +}); + it('renders windowSize derived region in middle', async () => { const items = generateItems(10); const ITEM_HEIGHT = 10;