Skip to content

Commit 6b3a15b

Browse files
committed
Optimize VirtualizedList sticky header lookup
1 parent 066c0d8 commit 6b3a15b

3 files changed

Lines changed: 178 additions & 13 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @fantom_mode dev
8+
* @flow strict-local
9+
* @format
10+
*/
11+
12+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
13+
14+
import * as Fantom from '@react-native/fantom';
15+
import VirtualizedList from '@react-native/virtualized-lists/Lists/VirtualizedList';
16+
17+
const VIEWPORT_SIZE = 100;
18+
const ROW_COUNTS = [100000, 250000, 500000, 750000, 1000000];
19+
20+
type StickyHeaderCase = {
21+
itemCount: number,
22+
name: string,
23+
stickyHeaderIndices?: ReadonlyArray<number>,
24+
};
25+
26+
type BenchmarkData = {
27+
length: number,
28+
};
29+
30+
const benchmarkCases: Array<StickyHeaderCase> = [];
31+
32+
for (let i = 0; i < ROW_COUNTS.length; i++) {
33+
const itemCount = ROW_COUNTS[i];
34+
const label = itemCount === 1000000 ? '1m' : `${itemCount / 1000}k`;
35+
36+
benchmarkCases.push(
37+
{
38+
itemCount,
39+
name: `${label} rows without sticky headers`,
40+
},
41+
{
42+
itemCount,
43+
name: `${label} rows with empty sticky headers`,
44+
stickyHeaderIndices: [],
45+
},
46+
{
47+
itemCount,
48+
name: `${label} rows with one sticky header at the top`,
49+
stickyHeaderIndices: [0],
50+
},
51+
);
52+
}
53+
54+
function createProps(
55+
itemCount: number,
56+
stickyHeaderIndices?: ReadonlyArray<number>,
57+
) {
58+
return {
59+
data: {length: itemCount},
60+
getItem: (_data: BenchmarkData, index: number) => index,
61+
getItemCount: (data: BenchmarkData) => data.length,
62+
initialScrollIndex: 1,
63+
stickyHeaderIndices,
64+
};
65+
}
66+
67+
Fantom.unstable_benchmark
68+
.suite('VirtualizedList sticky headers', {
69+
disableOptimizedBuildCheck: true,
70+
minIterations: 100,
71+
})
72+
.test.each(
73+
benchmarkCases,
74+
benchmarkCase => `create render mask for ${benchmarkCase.name}`,
75+
benchmarkCase => {
76+
// $FlowExpectedError[prop-missing] Benchmark exercises an internal helper.
77+
VirtualizedList._createRenderMask(
78+
createProps(benchmarkCase.itemCount, benchmarkCase.stickyHeaderIndices),
79+
{
80+
first: benchmarkCase.itemCount - VIEWPORT_SIZE,
81+
last: benchmarkCase.itemCount - 1,
82+
},
83+
);
84+
},
85+
);

packages/virtualized-lists/Lists/VirtualizedList.js

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -535,16 +535,18 @@ class VirtualizedList extends StateSafePureComponent<
535535
renderMask.addCells(initialRegion);
536536
}
537537

538-
// The layout coordinates of sticker headers may be off-screen while the
538+
// The layout coordinates of sticky headers may be off-screen while the
539539
// actual header is on-screen. Keep the most recent before the viewport
540540
// rendered, even if its layout coordinates are not in viewport.
541-
const stickyIndicesSet = new Set(props.stickyHeaderIndices);
542-
VirtualizedList._ensureClosestStickyHeader(
543-
props,
544-
stickyIndicesSet,
545-
renderMask,
546-
cellsAroundViewport.first,
547-
);
541+
const stickyHeaderIndices = props.stickyHeaderIndices;
542+
if (stickyHeaderIndices != null && stickyHeaderIndices.length > 0) {
543+
VirtualizedList._ensureClosestStickyHeader(
544+
props,
545+
stickyHeaderIndices,
546+
renderMask,
547+
cellsAroundViewport.first,
548+
);
549+
}
548550
}
549551

550552
return renderMask;
@@ -575,18 +577,30 @@ class VirtualizedList extends StateSafePureComponent<
575577

576578
static _ensureClosestStickyHeader(
577579
props: VirtualizedListProps,
578-
stickyIndicesSet: Set<number>,
580+
stickyHeaderIndices: ReadonlyArray<number>,
579581
renderMask: CellRenderMask,
580582
cellIdx: number,
581583
) {
582584
const stickyOffset = props.ListHeaderComponent ? 1 : 0;
585+
const targetStickyIndex = cellIdx + stickyOffset;
586+
let closestStickyIndex = null;
583587

584-
for (let itemIdx = cellIdx - 1; itemIdx >= 0; itemIdx--) {
585-
if (stickyIndicesSet.has(itemIdx + stickyOffset)) {
586-
renderMask.addCells({first: itemIdx, last: itemIdx});
587-
break;
588+
for (let itemIdx = 0; itemIdx < stickyHeaderIndices.length; itemIdx++) {
589+
const stickyIndex = stickyHeaderIndices[itemIdx];
590+
if (
591+
Number.isInteger(stickyIndex) &&
592+
stickyIndex < targetStickyIndex &&
593+
stickyIndex >= stickyOffset &&
594+
(closestStickyIndex == null || stickyIndex > closestStickyIndex)
595+
) {
596+
closestStickyIndex = stickyIndex;
588597
}
589598
}
599+
600+
if (closestStickyIndex != null) {
601+
const itemIdx = closestStickyIndex - stickyOffset;
602+
renderMask.addCells({first: itemIdx, last: itemIdx});
603+
}
590604
}
591605

592606
_adjustCellsAroundViewport(

packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,52 @@ describe('VirtualizedList', () => {
988988
// scrolled-past in layout space.
989989
expect(component).toMatchSnapshot();
990990
});
991+
992+
it('does not add a sticky header to the render mask when no sticky headers are configured', () => {
993+
const expectedRegions = [
994+
{first: 0, last: 9, isSpacer: true},
995+
{first: 10, last: 12, isSpacer: false},
996+
{first: 13, last: 19, isSpacer: true},
997+
];
998+
999+
expect(createRenderMaskForStickyHeaderTest().enumerateRegions()).toEqual(
1000+
expectedRegions,
1001+
);
1002+
expect(
1003+
createRenderMaskForStickyHeaderTest({
1004+
stickyHeaderIndices: [],
1005+
}).enumerateRegions(),
1006+
).toEqual(expectedRegions);
1007+
});
1008+
1009+
it('adds the closest sticky header above the viewport from unsorted stickyHeaderIndices', () => {
1010+
expect(
1011+
createRenderMaskForStickyHeaderTest({
1012+
stickyHeaderIndices: [12, 0, 8.5, 7, 7, -1],
1013+
}).enumerateRegions(),
1014+
).toEqual([
1015+
{first: 0, last: 6, isSpacer: true},
1016+
{first: 7, last: 7, isSpacer: false},
1017+
{first: 8, last: 9, isSpacer: true},
1018+
{first: 10, last: 12, isSpacer: false},
1019+
{first: 13, last: 19, isSpacer: true},
1020+
]);
1021+
});
1022+
1023+
it('accounts for ListHeaderComponent offset when adding the closest sticky header', () => {
1024+
expect(
1025+
createRenderMaskForStickyHeaderTest({
1026+
ListHeaderComponent: () => createElement('Header'),
1027+
stickyHeaderIndices: [3],
1028+
}).enumerateRegions(),
1029+
).toEqual([
1030+
{first: 0, last: 1, isSpacer: true},
1031+
{first: 2, last: 2, isSpacer: false},
1032+
{first: 3, last: 9, isSpacer: true},
1033+
{first: 10, last: 12, isSpacer: false},
1034+
{first: 13, last: 19, isSpacer: true},
1035+
]);
1036+
});
9911037
});
9921038

9931039
it('unmounts sticky headers moved below viewport', async () => {
@@ -2569,6 +2615,26 @@ function fixedHeightItemLayoutProps(height) {
25692615
};
25702616
}
25712617

2618+
function createRenderMaskForStickyHeaderTest({
2619+
ListHeaderComponent,
2620+
stickyHeaderIndices: stickyHeaderIndicesForTest,
2621+
} = {}) {
2622+
return VirtualizedList._createRenderMask(
2623+
{
2624+
data: {length: 20},
2625+
getItem: (data, index) => index,
2626+
getItemCount: data => data.length,
2627+
initialScrollIndex: 1,
2628+
ListHeaderComponent,
2629+
stickyHeaderIndices: stickyHeaderIndicesForTest,
2630+
},
2631+
{
2632+
first: 10,
2633+
last: 12,
2634+
},
2635+
);
2636+
}
2637+
25722638
let lastViewportLayout;
25732639
let lastContentLayout;
25742640

0 commit comments

Comments
 (0)