From 5cfd2e2d8230d4d38ed84f3c1250f31fd56ffefe Mon Sep 17 00:00:00 2001 From: "sedat.ciftci" Date: Fri, 18 Sep 2026 15:51:45 +0300 Subject: [PATCH] Reduce allocations in VirtualizedList render and scroll path --- .../Lists/ChildListCollection.js | 5 ++ .../Lists/VirtualizeUtils.js | 12 +-- .../Lists/VirtualizedList.js | 38 +++++++-- .../__tests__/ChildListCollection-test.js | 60 ++++++++++++++ .../Lists/__tests__/VirtualizeUtils-test.js | 33 ++++++++ .../Lists/__tests__/VirtualizedList-test.js | 79 +++++++++++++++++++ 6 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 packages/virtualized-lists/Lists/__tests__/ChildListCollection-test.js diff --git a/packages/virtualized-lists/Lists/ChildListCollection.js b/packages/virtualized-lists/Lists/ChildListCollection.js index 9c12031f6a19..083b565c6c28 100644 --- a/packages/virtualized-lists/Lists/ChildListCollection.js +++ b/packages/virtualized-lists/Lists/ChildListCollection.js @@ -42,6 +42,11 @@ export default class ChildListCollection { } forEach(fn: TList => void): void { + // Fast-path for the common case of a list without nested child lists, + // which avoids allocating a Map iterator on every scroll event. + if (this._cellKeyToChildren.size === 0) { + return; + } for (const listSet of this._cellKeyToChildren.values()) { for (const list of listSet) { fn(list); diff --git a/packages/virtualized-lists/Lists/VirtualizeUtils.js b/packages/virtualized-lists/Lists/VirtualizeUtils.js index 4e5c6877fc77..056729dd6702 100644 --- a/packages/virtualized-lists/Lists/VirtualizeUtils.js +++ b/packages/virtualized-lists/Lists/VirtualizeUtils.js @@ -244,11 +244,13 @@ export function computeWindowedRenderLimits( } export function keyExtractor(item: any, index: number): string { - if (typeof item === 'object' && item?.key != null) { - return item.key; - } - if (typeof item === 'object' && item?.id != null) { - return item.id; + if (item != null && typeof item === 'object') { + if (item.key != null) { + return item.key; + } + if (item.id != null) { + return item.id; + } } return String(index); } diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 413d14d53d29..d2aed08e649a 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -785,7 +785,7 @@ class VirtualizedList extends StateSafePureComponent< _pushCells( cells: Array, stickyHeaderIndices: Array, - stickyIndicesFromProps: Set, + stickyIndicesFromProps: null | Set, first: number, last: number, inversionStyle: StyleProp, @@ -813,7 +813,10 @@ class VirtualizedList extends StateSafePureComponent< const key = VirtualizedList._keyExtractor(item, ii, this.props); this._indicesToKeys.set(ii, key); - if (stickyIndicesFromProps.has(ii + stickyOffset)) { + if ( + stickyIndicesFromProps != null && + stickyIndicesFromProps.has(ii + stickyOffset) + ) { stickyHeaderIndices.push(cells.length); } @@ -944,12 +947,16 @@ class VirtualizedList extends StateSafePureComponent< : styles.verticallyInverted : null; const cells: Array = []; - const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); + // Avoid allocating a Set on every render when no sticky headers are + // configured (the common case). + const stickyHeaderIndicesProp = this.props.stickyHeaderIndices; + const stickyIndicesFromProps = + stickyHeaderIndicesProp != null ? new Set(stickyHeaderIndicesProp) : null; const stickyHeaderIndices = []; // 1. Add cell for ListHeaderComponent if (ListHeaderComponent) { - if (stickyIndicesFromProps.has(0)) { + if (stickyIndicesFromProps != null && stickyIndicesFromProps.has(0)) { stickyHeaderIndices.push(0); } const element = isValidElement(ListHeaderComponent) ? ( @@ -1231,6 +1238,8 @@ class VirtualizedList extends StateSafePureComponent< } } + _cachedOrientation: ?ListOrientation = null; + _cachedOrientationHorizontal: ?boolean = null; _cellRefs: {[string]: null | CellRenderer} = {}; _fillRateHelper: FillRateHelper; _listMetrics: ListMetricsAggregator = new ListMetricsAggregator(); @@ -1552,10 +1561,23 @@ class VirtualizedList extends StateSafePureComponent< } _orientation(): ListOrientation { - return { - horizontal: horizontalOrDefault(this.props.horizontal), - rtl: I18nManager.isRTL, - }; + // The orientation is stable for the lifetime of the list unless the + // `horizontal` prop changes (I18nManager.isRTL only changes on app + // reload). Cache the object to avoid allocating it on the scroll path. + const horizontal = horizontalOrDefault(this.props.horizontal); + let cachedOrientation = this._cachedOrientation; + if ( + cachedOrientation == null || + this._cachedOrientationHorizontal !== horizontal + ) { + cachedOrientation = { + horizontal, + rtl: I18nManager.isRTL, + }; + this._cachedOrientation = cachedOrientation; + this._cachedOrientationHorizontal = horizontal; + } + return cachedOrientation; } _maybeCallOnEdgeReached() { diff --git a/packages/virtualized-lists/Lists/__tests__/ChildListCollection-test.js b/packages/virtualized-lists/Lists/__tests__/ChildListCollection-test.js new file mode 100644 index 000000000000..49bc213cb029 --- /dev/null +++ b/packages/virtualized-lists/Lists/__tests__/ChildListCollection-test.js @@ -0,0 +1,60 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import ChildListCollection from '../ChildListCollection'; + +describe('ChildListCollection', function () { + it('iterates over all child lists with forEach', function () { + const collection = new ChildListCollection(); + collection.add('a', 'cell1'); + collection.add('b', 'cell1'); + collection.add('c', 'cell2'); + + const visited = []; + collection.forEach(list => visited.push(list)); + expect(visited.sort()).toEqual(['a', 'b', 'c']); + expect(collection.size()).toBe(3); + }); + + it('does not call the callback when the collection is empty', function () { + const collection = new ChildListCollection(); + const callback = jest.fn(); + collection.forEach(callback); + expect(callback).not.toHaveBeenCalled(); + expect(collection.size()).toBe(0); + }); + + it('stops iterating entries after they are removed', function () { + const collection = new ChildListCollection(); + collection.add('a', 'cell1'); + collection.remove('a'); + + const visited = []; + collection.forEach(list => visited.push(list)); + expect(visited).toEqual([]); + expect(collection.size()).toBe(0); + }); + + it('supports forEachInCell and anyInCell', function () { + const collection = new ChildListCollection(); + collection.add('a', 'cell1'); + collection.add('b', 'cell2'); + + const visited = []; + collection.forEachInCell('cell1', list => visited.push(list)); + expect(visited).toEqual(['a']); + + expect(collection.anyInCell('cell2', list => list === 'b')).toBe(true); + expect(collection.anyInCell('cell1', list => list === 'b')).toBe(false); + expect(collection.anyInCell('missing', () => true)).toBe(false); + }); +}); diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js index 6b6ba04d4848..ea5cb0267702 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js @@ -16,6 +16,7 @@ import ListMetricsAggregator from '../ListMetricsAggregator'; import { computeWindowedRenderLimits, elementsThatOverlapOffsets, + keyExtractor, newRangeCount, } from '../VirtualizeUtils'; import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; @@ -292,3 +293,35 @@ describe('computeWindowedRenderLimits', function () { expect(result).toEqual({first: 0, last: 4}); }); }); + +describe('keyExtractor', function () { + it('prefers item.key', function () { + expect(keyExtractor({key: 'k', id: 1}, 0)).toBe('k'); + }); + + it('falls back to item.id when key is missing', function () { + expect(keyExtractor({id: 42}, 0)).toBe(42); + }); + + it('treats explicit null key as missing', function () { + expect(keyExtractor({key: null, id: 9}, 0)).toBe(9); + }); + + it('returns explicitly set falsy key and id values', function () { + expect(keyExtractor({key: 0}, 0)).toBe(0); + expect(keyExtractor({key: false}, 0)).toBe(false); + expect(keyExtractor({key: null, id: 0}, 0)).toBe(0); + }); + + it('falls back to the index for items without key or id', function () { + expect(keyExtractor({}, 7)).toBe('7'); + }); + + it('falls back to the index for null, undefined, primitives and arrays', function () { + expect(keyExtractor(null, 1)).toBe('1'); + expect(keyExtractor(undefined, 2)).toBe('2'); + expect(keyExtractor('str', 3)).toBe('3'); + expect(keyExtractor(42, 4)).toBe('4'); + expect(keyExtractor([], 5)).toBe('5'); + }); +}); diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index 52b061f7e04b..26643cfdd40f 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -1052,6 +1052,85 @@ describe('VirtualizedList', () => { expect(component).toMatchSnapshot(); }); + it('does not forward stickyHeaderIndices when the prop is absent', async () => { + let scrollProps; + await act(() => { + create( + createElement('Header')} + data={[{key: 'i1'}, {key: 'i2'}]} + renderItem={({item}) => } + getItem={(data, index) => data[index]} + getItemCount={data => data.length} + renderScrollComponent={props => { + scrollProps = props; + return createElement('MockScrollView', props); + }} + />, + ); + }); + expect(scrollProps).not.toBe(undefined); + expect(scrollProps.stickyHeaderIndices).toEqual([]); + }); + + it('forwards stickyHeaderIndices including the header index when provided', async () => { + let scrollProps; + await act(() => { + create( + createElement('Header')} + data={[{key: 'i1'}, {key: 'i2'}]} + renderItem={({item}) => } + getItem={(data, index) => data[index]} + getItemCount={data => data.length} + stickyHeaderIndices={[0]} + renderScrollComponent={props => { + scrollProps = props; + return createElement('MockScrollView', props); + }} + />, + ); + }); + expect(scrollProps).not.toBe(undefined); + expect(scrollProps.stickyHeaderIndices).toEqual([0]); + }); + + it('caches orientation and invalidates the cache when horizontal changes', async () => { + let component; + await act(() => { + component = create( + } + getItem={(data, index) => data[index]} + getItemCount={data => data.length} + />, + ); + }); + + const instance = component.getInstance(); + const firstOrientation = instance._orientation(); + expect(instance._orientation()).toBe(firstOrientation); + expect(firstOrientation.horizontal).toBe(false); + + await act(() => { + component.update( + } + getItem={(data, index) => data[index]} + getItemCount={data => data.length} + />, + ); + }); + + const secondOrientation = instance._orientation(); + expect(secondOrientation).not.toBe(firstOrientation); + expect(secondOrientation.horizontal).toBe(true); + expect(instance._orientation()).toBe(secondOrientation); + }); + it('does not add a sticky header to the render mask when no sticky headers are configured', () => { const expectedRegions = [ {first: 0, last: 9, isSpacer: true},