diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js index b9551b65542..9a4dc912c5f 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 24bb057cc04..539a0b32c4b 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}) => ( +