From e299f1ab799fa481a74771b952903aaf156c76de Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Thu, 13 Aug 2026 19:36:02 +0700 Subject: [PATCH] Do not run SectionList header/footer rows through the section keyExtractor VirtualizedSectionList._convertViewable ran every viewable row through the section's keyExtractor. For section header and footer rows _getItem returns the section object itself, so a keyExtractor written for items received {title, data, keyExtractor} and crashed. Use the key _subExtractor already derived for those rows instead, matching what _keyExtractor does on the non-viewability path. --- .../Lists/VirtualizedSectionList.js | 25 +++-- .../__tests__/VirtualizedSectionList-test.js | 95 +++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js index b9551b655421..9a4dc912c5f9 100644 --- a/packages/virtualized-lists/Lists/VirtualizedSectionList.js +++ b/packages/virtualized-lists/Lists/VirtualizedSectionList.js @@ -321,13 +321,24 @@ class VirtualizedSectionList< if (!info) { return null; } - const keyExtractorWithNullableIndex = info.section.keyExtractor; - const keyExtractorWithNonNullableIndex = - this.props.keyExtractor || defaultKeyExtractor; - const key = - keyExtractorWithNullableIndex != null - ? keyExtractorWithNullableIndex(viewable.item, info.index) - : keyExtractorWithNonNullableIndex(viewable.item, info.index ?? 0); + // Section headers and footers are not items: `_getItem` returns the + // section itself for those rows, and `_subExtractor` reports a null index + // for them. Passing a section to a key extractor written for items is + // unsafe, so reuse the key `_subExtractor` already derived from the + // section, which is also what `_keyExtractor` uses for those rows. + let key; + if (info.index == null) { + key = info.key; + } else { + const sectionKeyExtractor = info.section.keyExtractor; + key = + sectionKeyExtractor != null + ? sectionKeyExtractor(viewable.item, info.index) + : (this.props.keyExtractor || defaultKeyExtractor)( + viewable.item, + info.index, + ); + } return { ...viewable, diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js index 24bb057cc04e..539a0b32c4b5 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js @@ -215,6 +215,101 @@ describe('VirtualizedSectionList', () => { expect(component).toMatchSnapshot(); }); + describe('onViewableItemsChanged', () => { + const ITEM_HEIGHT = 100; + + type Item = {nested: {id: string}}; + + // Six of the eight rows (2 headers, 4 items, 2 footers) fit in the + // viewport, so both section headers, one section footer and three items + // become viewable. + const nativeEvent = { + contentInset: {bottom: 0, left: 0, right: 0, top: 0}, + contentOffset: {x: 0, y: 0}, + contentSize: {height: 8 * ITEM_HEIGHT, width: 300}, + layoutMeasurement: {height: 6 * ITEM_HEIGHT, width: 300}, + zoomScale: 1, + }; + + it('reports section headers and footers without running them through the section keyExtractor', async () => { + // A key extractor written for items, the way a section defines one. It + // throws if it is handed anything other than an item. + const extractorArgs: Array = []; + const keyExtractor = (item: ?Item) => { + const arg = nullthrows(item); + extractorArgs.push(arg); + return arg.nested.id; + }; + const sections = [ + // $FlowFixMe[incompatible-type] + { + title: 's1', + keyExtractor, + data: [{nested: {id: 'i1.1'}}, {nested: {id: 'i1.2'}}], + }, + // $FlowFixMe[incompatible-type] + { + title: 's2', + keyExtractor, + data: [{nested: {id: 'i2.1'}}, {nested: {id: 'i2.2'}}], + }, + ] as Array>; + const onViewableItemsChanged = jest.fn(); + + let component; + await ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + } + renderSectionHeader={({section}) => ( +
+ )} + renderSectionFooter={({section}) => ( +