diff --git a/packages/virtualized-lists/Lists/ChildListCollection.js b/packages/virtualized-lists/Lists/ChildListCollection.js index 9c12031f6a1..083b565c6c2 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/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 4e56139b392..d1911db17f4 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -786,7 +786,7 @@ class VirtualizedList extends StateSafePureComponent< _pushCells( cells: Array, stickyHeaderIndices: Array, - stickyIndicesFromProps: Set, + stickyIndicesFromProps: null | Set, first: number, last: number, inversionStyle: StyleProp, @@ -814,7 +814,7 @@ class VirtualizedList extends StateSafePureComponent< const key = VirtualizedList._keyExtractor(item, ii, this.props); this._indicesToKeys.set(ii, key); - if (stickyIndicesFromProps.has(ii + stickyOffset)) { + if (stickyIndicesFromProps?.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); } @@ -945,12 +945,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) ? ( @@ -1232,6 +1236,7 @@ class VirtualizedList extends StateSafePureComponent< } } + _cachedOrientation: ?ListOrientation = null; _cellRefs: {[string]: null | CellRenderer} = {}; _fillRateHelper: FillRateHelper; _listMetrics: ListMetricsAggregator = new ListMetricsAggregator(); @@ -1553,10 +1558,22 @@ 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 || + cachedOrientation.horizontal !== horizontal + ) { + cachedOrientation = { + horizontal, + rtl: I18nManager.isRTL, + }; + this._cachedOrientation = cachedOrientation; + } + 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 00000000000..3f4b0e70bdb --- /dev/null +++ b/packages/virtualized-lists/Lists/__tests__/ChildListCollection-test.js @@ -0,0 +1,66 @@ +/** + * 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__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index 52b061f7e04..26643cfdd40 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},