Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/virtualized-lists/Lists/ChildListCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export default class ChildListCollection<TList> {
}

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);
Expand Down
12 changes: 7 additions & 5 deletions packages/virtualized-lists/Lists/VirtualizeUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
38 changes: 30 additions & 8 deletions packages/virtualized-lists/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ class VirtualizedList extends StateSafePureComponent<
_pushCells(
cells: Array<Object>,
stickyHeaderIndices: Array<number>,
stickyIndicesFromProps: Set<number>,
stickyIndicesFromProps: null | Set<number>,
first: number,
last: number,
inversionStyle: StyleProp<ViewStyle>,
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -944,12 +947,16 @@ class VirtualizedList extends StateSafePureComponent<
: styles.verticallyInverted
: null;
const cells: Array<any | React.Node> = [];
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) ? (
Expand Down Expand Up @@ -1231,6 +1238,8 @@ class VirtualizedList extends StateSafePureComponent<
}
}

_cachedOrientation: ?ListOrientation = null;
_cachedOrientationHorizontal: ?boolean = null;
_cellRefs: {[string]: null | CellRenderer<any>} = {};
_fillRateHelper: FillRateHelper;
_listMetrics: ListMetricsAggregator = new ListMetricsAggregator();
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string>();
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<string>();
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<string>();
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);
});
});
33 changes: 33 additions & 0 deletions packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
79 changes: 79 additions & 0 deletions packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<VirtualizedList
ListHeaderComponent={() => createElement('Header')}
data={[{key: 'i1'}, {key: 'i2'}]}
renderItem={({item}) => <item value={item.key} />}
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(
<VirtualizedList
ListHeaderComponent={() => createElement('Header')}
data={[{key: 'i1'}, {key: 'i2'}]}
renderItem={({item}) => <item value={item.key} />}
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(
<VirtualizedList
data={[{key: 'i1'}]}
renderItem={({item}) => <item value={item.key} />}
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(
<VirtualizedList
horizontal={true}
data={[{key: 'i1'}]}
renderItem={({item}) => <item value={item.key} />}
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},
Expand Down
Loading