diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js index 55a63f8866ff..f8229ac1a9b1 100644 --- a/packages/virtualized-lists/Lists/VirtualizedSectionList.js +++ b/packages/virtualized-lists/Lists/VirtualizedSectionList.js @@ -532,24 +532,49 @@ function ItemWithSeparator( const [separatorHighlighted, setSeparatorHighlighted] = useState(false); - const [leadingSeparatorProps, setLeadingSeparatorProps] = useState< - ItemWithSeparatorCommonProps, - >({ + const propsLeadingSeparatorProps: ItemWithSeparatorCommonProps = { leadingItem: props.leadingItem, leadingSection: props.leadingSection, section: props.section, trailingItem: props.item, trailingSection: props.trailingSection, - }); - const [separatorProps, setSeparatorProps] = useState< - ItemWithSeparatorCommonProps, - >({ + }; + const propsSeparatorProps: ItemWithSeparatorCommonProps = { leadingItem: props.item, leadingSection: props.leadingSection, section: props.section, trailingItem: props.trailingItem, trailingSection: props.trailingSection, - }); + }; + + const [leadingSeparatorProps, setLeadingSeparatorProps] = useState< + ItemWithSeparatorCommonProps, + >(propsLeadingSeparatorProps); + const [separatorProps, setSeparatorProps] = useState< + ItemWithSeparatorCommonProps, + >(propsSeparatorProps); + + // Same cell can re-render with new leading/trailing items (e.g. after a + // section reorder). Sync derived state from props during render so + // ItemSeparatorComponent doesn't receive stale leadingItem/trailingItem. + if ( + leadingSeparatorProps.leadingItem !== propsLeadingSeparatorProps.leadingItem || + leadingSeparatorProps.trailingItem !== propsLeadingSeparatorProps.trailingItem || + leadingSeparatorProps.leadingSection !== propsLeadingSeparatorProps.leadingSection || + leadingSeparatorProps.section !== propsLeadingSeparatorProps.section || + leadingSeparatorProps.trailingSection !== propsLeadingSeparatorProps.trailingSection + ) { + setLeadingSeparatorProps(propsLeadingSeparatorProps); + } + if ( + separatorProps.leadingItem !== propsSeparatorProps.leadingItem || + separatorProps.trailingItem !== propsSeparatorProps.trailingItem || + separatorProps.leadingSection !== propsSeparatorProps.leadingSection || + separatorProps.section !== propsSeparatorProps.section || + separatorProps.trailingSection !== propsSeparatorProps.trailingSection + ) { + setSeparatorProps(propsSeparatorProps); + } useEffect(() => { setSelfHighlightCallback(cellKey, setSeparatorHighlighted); diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js index cc6b1da8d266..a89e28af02ac 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js @@ -202,6 +202,73 @@ describe('VirtualizedSectionList', () => { expect(component).toMatchSnapshot(); }); + it('passes fresh leadingItem and trailingItem to ItemSeparatorComponent after sections reorder', async () => { + // Regression test for https://github.com/facebook/react-native/issues/55708. + // ItemWithSeparator stored leadingItem/trailingItem in useState seeded from + // the first render, so when the same cell key re-rendered with reordered + // data the separator kept the stale items (or undefined when the previous + // row was at a section boundary). + const separatorPropsByKey: { + [string]: Array<{leading: ?string, trailing: ?string}>, + } = {}; + const RecordingSeparator = (props: $FlowFixMe) => { + const itemKey = props.leadingItem?.key ?? '__head__'; + separatorPropsByKey[itemKey] = separatorPropsByKey[itemKey] ?? []; + separatorPropsByKey[itemKey].push({ + leading: props.leadingItem?.key, + trailing: props.trailingItem?.key, + }); + return ; + }; + + const initial = [ + // $FlowFixMe[incompatible-type] + {title: 's', data: [{key: 'a'}, {key: 'b'}, {key: 'c'}]}, + ]; + const reordered = [ + // $FlowFixMe[incompatible-type] + {title: 's', data: [{key: 'b'}, {key: 'a'}, {key: 'c'}]}, + ]; + + let component; + await ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + } + getItem={(data, key) => data[key]} + getItemCount={data => data.length} + ItemSeparatorComponent={RecordingSeparator} + />, + ); + }); + + await ReactTestRenderer.act(() => { + nullthrows(component).update( + } + getItem={(data, key) => data[key]} + getItemCount={data => data.length} + ItemSeparatorComponent={RecordingSeparator} + />, + ); + }); + + // Last render of separator below "b" must reflect the reordered list: + // the item below b is now a, so trailing must be 'a'. + const lastBelowB = nullthrows(separatorPropsByKey['b']).at(-1); + expect(lastBelowB?.leading).toBe('b'); + expect(lastBelowB?.trailing).toBe('a'); + + // Last render of separator below "a" must reflect a→c. + const lastBelowA = nullthrows(separatorPropsByKey['a']).at(-1); + expect(lastBelowA?.leading).toBe('a'); + expect(lastBelowA?.trailing).toBe('c'); + }); + describe('scrollToLocation', () => { const ITEM_HEIGHT = 100;