Skip to content

Commit fe53279

Browse files
tarikfpmeta-codesync[bot]
authored andcommitted
Avoid sticky header scans for non-sticky VirtualizedLists (#57210)
Summary: `VirtualizedList._createRenderMask` always did the sticky-header lookup, even when there were no sticky headers. For a large list scrolled far from the top, that meant walking backward from the first visible item toward index 0 on every render-mask update. This changes that path to: - skip the lookup when `stickyHeaderIndices` is missing or empty - when sticky headers exist, scan the sticky header indices and pick the closest one above the viewport - keep `ListHeaderComponent` offset handling and integer-index behavior ## Changelog: [GENERAL][CHANGED] - Speed up VirtualizedList render-mask creation for large lists by avoiding the old backward sticky-header scan when sticky headers are missing or sparse. ## Affected components This is inside `VirtualizedList`, so the affected callers are: - `VirtualizedList` - `FlatList`, because it renders through `VirtualizedList` - `VirtualizedSectionList` / `SectionList`, because section lists also render through this path `stickyHeaderIndices` does not need to be set to get the no-sticky win. A normal `FlatList` with no sticky headers still used to pay the backward scan. With this change, that case exits before the sticky-header helper runs. When `stickyHeaderIndices` is set, the lookup changes from scanning item indices back toward 0 to scanning only the sticky header index array. The size of the win then depends on how many sticky headers are configured. ## Benchmark Benchmark command: ```sh yarn fantom --benchmarks packages/react-native/Libraries/Lists/__tests__/VirtualizedList-stickyHeaders-benchmark-itest.js --runInBand ``` Fantom Hermes benchmark, 100 samples per case. Values below are median latency for `VirtualizedList._createRenderMask` when the viewport is near the end of the list. Per-call values are under 1 second, so they stay in milliseconds. For 1,000 render-mask updates, values over 1 second are shown in seconds. | Case | One update before | One update after | Speedup | 1,000 updates before | 1,000 updates after | | --- | ---: | ---: | ---: | ---: | ---: | | 100k rows, no sticky headers | 2.664 ms | 0.0034 ms | 780x | 2.66 s | 3.42 ms | | 100k rows, empty sticky headers | 2.632 ms | 0.0033 ms | 800x | 2.63 s | 3.29 ms | | 100k rows, one top sticky header | 2.705 ms | 0.0054 ms | 499x | 2.71 s | 5.42 ms | | 250k rows, no sticky headers | 6.543 ms | 0.0035 ms | 1,892x | 6.54 s | 3.46 ms | | 250k rows, empty sticky headers | 6.579 ms | 0.0033 ms | 2,024x | 6.58 s | 3.25 ms | | 250k rows, one top sticky header | 6.796 ms | 0.0053 ms | 1,294x | 6.80 s | 5.25 ms | | 500k rows, no sticky headers | 13.173 ms | 0.0033 ms | 4,002x | 13.17 s | 3.29 ms | | 500k rows, empty sticky headers | 13.073 ms | 0.0033 ms | 3,997x | 13.07 s | 3.27 ms | | 500k rows, one top sticky header | 13.444 ms | 0.0054 ms | 2,511x | 13.44 s | 5.35 ms | | 750k rows, no sticky headers | 19.524 ms | 0.0033 ms | 6,008x | 19.52 s | 3.25 ms | | 750k rows, empty sticky headers | 19.572 ms | 0.0033 ms | 6,022x | 19.57 s | 3.25 ms | | 750k rows, one top sticky header | 20.304 ms | 0.0054 ms | 3,778x | 20.30 s | 5.37 ms | | 1m rows, no sticky headers | 26.190 ms | 0.0033 ms | 8,058x | 26.19 s | 3.25 ms | | 1m rows, empty sticky headers | 26.039 ms | 0.0033 ms | 8,012x | 26.04 s | 3.25 ms | | 1m rows, one top sticky header | 26.855 ms | 0.0053 ms | 5,075x | 26.86 s | 5.29 ms | This benchmark is intentionally focused on this helper. It does not claim the whole app or whole list render becomes thousands of times faster. It shows that this hot helper is no longer proportional to the scroll distance from the top of the list. Pull Request resolved: #57210 Test Plan: ```sh yarn jest packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js packages/react-native/Libraries/Lists/__tests__/FlatList-test.js --runInBand ``` Passed: 3 suites, 101 tests, 1 skipped, 76 snapshots. ```sh yarn fantom packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js packages/react-native/Libraries/Lists/__tests__/SectionList-itest.js --runInBand ``` Passed: 2 suites, 64 tests. ```sh yarn flow check ``` Passed: no errors. ```sh ./node_modules/.bin/eslint packages/virtualized-lists/Lists/VirtualizedList.js packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js packages/react-native/Libraries/Lists/__tests__/VirtualizedList-stickyHeaders-benchmark-itest.js ``` Passed. ```sh git diff --check ``` Passed. Reviewed By: javache Differential Revision: D108890210 Pulled By: Abbondanzo fbshipit-source-id: 7548ba29d77ac14a609665356ab7d09662820abe
1 parent 9ab5dd8 commit fe53279

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)