Skip to content

Commit e998820

Browse files
amroaltahfacebook-github-bot
authored andcommitted
Fix VirtualizedSectionList.scrollToLocation off-by-one and sticky header offset (#58329)
Summary: `VirtualizedSectionList.scrollToLocation`, and `SectionList` through its wrapper, mapped `itemIndex` to the underlying flat list without skipping the section header. As a result, `itemIndex: 0` targeted the header and every other item was one row early. Sticky-header compensation was also skipped for the first item, allowing the header to obscure it. This change adds the header row to the flattened target index and always applies the sticky-header offset using the current section header metrics. The public API remains zero-based, but callers that compensated for the old behavior must remove that compensation. Migration: - Replace `itemIndex: n + 1` workarounds with `itemIndex: n`. - Replace `itemIndex: data.length` last-item workarounds with `itemIndex: data.length - 1`. Thanks to Marc Rousavy (mrousavy) for the diagnosis: #50143 Changelog: [General][Breaking] - Fix `SectionList` and `VirtualizedSectionList` `scrollToLocation` to account for section headers and sticky headers correctly. Reviewed By: javache, ryanfrawley Differential Revision: D118547738
1 parent 7a2963f commit e998820

2 files changed

Lines changed: 175 additions & 29 deletions

File tree

‎packages/virtualized-lists/Lists/VirtualizedSectionList.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ class VirtualizedSectionList<
138138
State,
139139
> {
140140
scrollToLocation(params: ScrollToLocationParamsType) {
141-
let index = params.itemIndex;
141+
let index = params.itemIndex + 1;
142142
for (let i = 0; i < params.sectionIndex; i++) {
143143
index += this.props.getItemCount(this.props.sections[i].data) + 2;
144144
}
@@ -147,10 +147,10 @@ class VirtualizedSectionList<
147147
return;
148148
}
149149
const listRef = this._listRef;
150-
if (params.itemIndex > 0 && this.props.stickySectionHeadersEnabled) {
150+
if (this.props.stickySectionHeadersEnabled) {
151151
const frame = listRef
152152
.__getListMetrics()
153-
.getCellMetricsApprox(index - params.itemIndex, listRef.props);
153+
.getCellMetricsApprox(index - params.itemIndex - 1, listRef.props);
154154
viewOffset += frame.length;
155155
}
156156
const toIndexParams: {

‎packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js‎

Lines changed: 172 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -219,35 +219,61 @@ describe('VirtualizedSectionList', () => {
219219
const ITEM_HEIGHT = 100;
220220

221221
const createVirtualizedSectionList = async (props?: {
222-
stickySectionHeadersEnabled: boolean,
222+
stickySectionHeadersEnabled?: boolean,
223+
sections?: Array<SectionBase<{key: string}>>,
224+
getItemLayout?: (
225+
data: unknown,
226+
index: number,
227+
) => {
228+
length: number,
229+
offset: number,
230+
index: number,
231+
},
223232
}) => {
233+
const defaultSections = [
234+
// $FlowFixMe[incompatible-type]
235+
{
236+
title: 's1',
237+
data: [{key: 'i1.1'}, {key: 'i1.2'}, {key: 'i1.3'}],
238+
},
239+
// $FlowFixMe[incompatible-type]
240+
{
241+
title: 's2',
242+
data: [{key: 'i2.1'}, {key: 'i2.2'}, {key: 'i2.3'}],
243+
},
244+
] as Array<SectionBase<{key: string}>>;
245+
246+
const sections = props?.sections ?? defaultSections;
247+
let getItemLayout;
248+
// Use `in` check to allow explicitly passing `getItemLayout: undefined`
249+
// to disable the default layout (distinct from not passing the prop at all).
250+
if (props != null && 'getItemLayout' in props) {
251+
getItemLayout = props.getItemLayout;
252+
} else {
253+
getItemLayout = (data: unknown, index: number) => ({
254+
length: ITEM_HEIGHT,
255+
offset: ITEM_HEIGHT * index,
256+
index,
257+
});
258+
}
259+
const {
260+
sections: _sections,
261+
getItemLayout: _getItemLayout,
262+
...restProps
263+
} = props ?? {};
264+
void _sections;
265+
void _getItemLayout;
266+
224267
let component;
225268
await ReactTestRenderer.act(() => {
226269
component = ReactTestRenderer.create(
227270
<VirtualizedSectionList
228-
sections={
229-
[
230-
// $FlowFixMe[incompatible-type]
231-
{
232-
title: 's1',
233-
data: [{key: 'i1.1'}, {key: 'i1.2'}, {key: 'i1.3'}],
234-
},
235-
// $FlowFixMe[incompatible-type]
236-
{
237-
title: 's2',
238-
data: [{key: 'i2.1'}, {key: 'i2.2'}, {key: 'i2.3'}],
239-
},
240-
] as Array<SectionBase<{key: string}>>
241-
}
271+
sections={sections}
242272
renderItem={({item}) => <item value={item.key} />}
243273
getItem={(data, key) => data[key]}
244274
getItemCount={data => data.length}
245-
getItemLayout={(data, index) => ({
246-
length: ITEM_HEIGHT,
247-
offset: ITEM_HEIGHT * index,
248-
index,
249-
})}
250-
{...props}
275+
getItemLayout={getItemLayout}
276+
{...restProps}
251277
/>,
252278
);
253279
});
@@ -265,7 +291,7 @@ describe('VirtualizedSectionList', () => {
265291
};
266292
};
267293

268-
it('when sticky stickySectionHeadersEnabled={true}, header height is added to the developer-provided viewOffset', async () => {
294+
it('when sticky headers enabled and itemIndex is 1, header height is added to viewOffset', async () => {
269295
const {instance, spy} = await createVirtualizedSectionList({
270296
stickySectionHeadersEnabled: true,
271297
});
@@ -279,7 +305,7 @@ describe('VirtualizedSectionList', () => {
279305
viewOffset,
280306
});
281307
expect(spy).toHaveBeenCalledWith({
282-
index: 1,
308+
index: 2,
283309
itemIndex: 1,
284310
sectionIndex: 0,
285311
viewOffset: viewOffset + ITEM_HEIGHT,
@@ -291,7 +317,7 @@ describe('VirtualizedSectionList', () => {
291317
// prevents #18098
292318
{sectionIndex: 0, itemIndex: 0},
293319
{
294-
index: 0,
320+
index: 1,
295321
itemIndex: 0,
296322
sectionIndex: 0,
297323
viewOffset: 0,
@@ -300,7 +326,7 @@ describe('VirtualizedSectionList', () => {
300326
[
301327
{sectionIndex: 2, itemIndex: 1},
302328
{
303-
index: 11,
329+
index: 12,
304330
itemIndex: 1,
305331
sectionIndex: 2,
306332
viewOffset: 0,
@@ -313,7 +339,7 @@ describe('VirtualizedSectionList', () => {
313339
viewOffset: 25,
314340
},
315341
{
316-
index: 1,
342+
index: 2,
317343
itemIndex: 1,
318344
sectionIndex: 0,
319345
viewOffset: 25,
@@ -328,5 +354,125 @@ describe('VirtualizedSectionList', () => {
328354
expect(spy).toHaveBeenCalledWith(expected);
329355
},
330356
);
357+
358+
it('scrolls to first item of first section', async () => {
359+
const {instance, spy} = await createVirtualizedSectionList();
360+
// $FlowFixMe[prop-missing] scrollToLocation not on instance
361+
instance?.scrollToLocation({sectionIndex: 0, itemIndex: 0});
362+
expect(spy).toHaveBeenCalledWith({
363+
index: 1,
364+
itemIndex: 0,
365+
sectionIndex: 0,
366+
viewOffset: 0,
367+
});
368+
});
369+
370+
it('scrolls to first item of a later section', async () => {
371+
const {instance, spy} = await createVirtualizedSectionList();
372+
// $FlowFixMe[prop-missing] scrollToLocation not on instance
373+
instance?.scrollToLocation({sectionIndex: 1, itemIndex: 0});
374+
expect(spy).toHaveBeenCalledWith({
375+
index: 6,
376+
itemIndex: 0,
377+
sectionIndex: 1,
378+
viewOffset: 0,
379+
});
380+
});
381+
382+
it('when sticky headers enabled and itemIndex is 0, header height is added to viewOffset (was previously skipped)', async () => {
383+
// Use distinct heights per index so only the correct header's height can satisfy the assertion.
384+
// Header at flat index 0 has height 37, item at index 1 has height 41 — an off-by-one
385+
// in the header lookup would produce 41 and fail.
386+
const HEADER_HEIGHT = 37;
387+
const ITEM_HEIGHT_DISTINCT = 41;
388+
const getItemLayout = (data: unknown, index: number) => ({
389+
length: index === 0 ? HEADER_HEIGHT : ITEM_HEIGHT_DISTINCT + index,
390+
offset: 0,
391+
index,
392+
});
393+
const {instance, spy} = await createVirtualizedSectionList({
394+
stickySectionHeadersEnabled: true,
395+
getItemLayout,
396+
});
397+
// $FlowFixMe[prop-missing] scrollToLocation not on instance
398+
instance?.scrollToLocation({sectionIndex: 0, itemIndex: 0});
399+
expect(spy).toHaveBeenCalledWith({
400+
index: 1,
401+
itemIndex: 0,
402+
sectionIndex: 0,
403+
viewOffset: HEADER_HEIGHT,
404+
});
405+
});
406+
407+
it('preserves caller-supplied viewOffset and adds header height when sticky', async () => {
408+
const {instance, spy} = await createVirtualizedSectionList({
409+
stickySectionHeadersEnabled: true,
410+
});
411+
// $FlowFixMe[prop-missing] scrollToLocation not on instance
412+
instance?.scrollToLocation({
413+
sectionIndex: 1,
414+
itemIndex: 0,
415+
viewOffset: 10,
416+
});
417+
expect(spy).toHaveBeenCalledWith({
418+
index: 6,
419+
itemIndex: 0,
420+
sectionIndex: 1,
421+
viewOffset: 10 + ITEM_HEIGHT,
422+
});
423+
});
424+
425+
it('preserves caller-supplied viewOffset without sticky headers', async () => {
426+
const {instance, spy} = await createVirtualizedSectionList();
427+
// $FlowFixMe[prop-missing] scrollToLocation not on instance
428+
instance?.scrollToLocation({
429+
sectionIndex: 1,
430+
itemIndex: 2,
431+
viewOffset: 15,
432+
});
433+
expect(spy).toHaveBeenCalledWith({
434+
index: 8,
435+
itemIndex: 2,
436+
sectionIndex: 1,
437+
viewOffset: 15,
438+
});
439+
});
440+
441+
it('handles out-of-range itemIndex', async () => {
442+
const {instance, spy} = await createVirtualizedSectionList();
443+
// $FlowFixMe[prop-missing] scrollToLocation not on instance
444+
instance?.scrollToLocation({sectionIndex: 1, itemIndex: 10});
445+
// 10 + 1 + (3 + 2) = 16, out of range for 10-item list but still forwarded
446+
expect(spy).toHaveBeenCalledWith({
447+
index: 16,
448+
itemIndex: 10,
449+
sectionIndex: 1,
450+
viewOffset: 0,
451+
});
452+
});
453+
454+
it('works with varying item heights and no getItemLayout', async () => {
455+
const {instance, spy} = await createVirtualizedSectionList({
456+
sections: [
457+
// $FlowFixMe[incompatible-type]
458+
{title: 's1', data: [{key: 'a1'}, {key: 'a2'}]},
459+
// $FlowFixMe[incompatible-type]
460+
{
461+
title: 's2',
462+
data: [{key: 'b1'}, {key: 'b2'}, {key: 'b3'}, {key: 'b4'}],
463+
},
464+
] as Array<SectionBase<{key: string}>>,
465+
getItemLayout: undefined,
466+
});
467+
// $FlowFixMe[prop-missing] scrollToLocation not on instance
468+
instance?.scrollToLocation({sectionIndex: 1, itemIndex: 0});
469+
// section 0: 2 items + header/footer = 4, so first item of section 1 is at 1 + 4 = 5
470+
expect(spy).toHaveBeenCalledWith({
471+
index: 5,
472+
itemIndex: 0,
473+
sectionIndex: 1,
474+
viewOffset: 0,
475+
});
476+
});
331477
});
332478
});

0 commit comments

Comments
 (0)