From fcd45231006224d23334da156edf3f14e8801cb8 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 1 Oct 2026 06:45:39 -0700 Subject: [PATCH] Fire VirtualizedList edge callbacks after programmatic scrolling (#58657) Summary: Programmatic list scrolling can move to an edge before asynchronously scheduled cells finish rendering. The scroll handler then skips the edge callback because the terminal cell is not yet in the render window, and no later scroll or layout event is guaranteed. Recheck edge callbacks after post-scroll render updates. Deduplicate each edge by item count and edge item key, re-arm on real movement away from the edge, and allow data-backed layout growth to start a new end-edge generation without treating measurement-only churn as a new callback. Changelog: [General][Fixed] - Fire `VirtualizedList` edge callbacks after programmatic scrolling Differential Revision: D100870075 --- .../examples/FlatList/BaseFlatListExample.js | 3 +- .../FlatList/FlatList-onEndReached.js | 126 ++++++++-- .../Lists/VirtualizedList.js | 151 +++++++++--- .../Lists/__tests__/VirtualizedList-test.js | 227 ++++++++++++++++++ 4 files changed, 459 insertions(+), 48 deletions(-) diff --git a/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js b/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js index 1c77581facfd..ff6b3fb27798 100644 --- a/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js +++ b/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js @@ -60,6 +60,7 @@ const Item = ({item, separators}: ListRenderItemInfo) => { }; type Props = Readonly<{ + data?: ReadonlyArray, exampleProps: Partial>, exampleTestID?: ?string, onTest?: ?() => void, @@ -95,7 +96,7 @@ const BaseFlatListExample: component( ref={ref} testID="flat_list" // $FlowFixMe[incompatible-type] - data={DATA} + data={props.data ?? DATA} keyExtractor={(item, index) => item + index} style={styles.list} // $FlowFixMe[incompatible-type] diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-onEndReached.js b/packages/rn-tester/js/examples/FlatList/FlatList-onEndReached.js index 132778d07b87..2a60d653160f 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-onEndReached.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-onEndReached.js @@ -12,33 +12,129 @@ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; -import BaseFlatListExample from './BaseFlatListExample'; +import BaseFlatListExample, {ITEM_HEIGHT} from './BaseFlatListExample'; import * as React from 'react'; import {useRef, useState} from 'react'; +import {Button, FlatList} from 'react-native'; + +const DATA = Array.from({length: 20}, (_, index) => `Item ${index}`); +const SCROLL_TO_ITEM = DATA[18]; + +type EventCounts = { + dragEvents: number, + onEndReached: number, + onMomentumScrollEnd: number, + onStartReached: number, +}; export component FlatList_onEndReached() { - const [output, setOutput] = useState(''); - const exampleProps = { - onEndReached: (info: {distanceFromEnd: number, ...}) => - setOutput('onEndReached'), - onEndReachedThreshold: 0, + const [output, setOutput] = useState('ready'); + const listRef = useRef>(null); + const actionRef = useRef<'end' | 'start'>('end'); + const attemptsRef = useRef(0); + const eventCountsRef = useRef({ + dragEvents: 0, + onEndReached: 0, + onMomentumScrollEnd: 0, + onStartReached: 0, + }); + const recordEventsRef = useRef(false); + const momentumEndedRef = useRef(false); + + const report = () => { + const {dragEvents, onEndReached, onMomentumScrollEnd, onStartReached} = + eventCountsRef.current; + setOutput( + `${actionRef.current}: attempts=${attemptsRef.current}, ` + + `onEndReached=${onEndReached}, onStartReached=${onStartReached}, ` + + `onMomentumScrollEnd=${onMomentumScrollEnd}, dragEvents=${dragEvents}`, + ); + }; + + const onEndReached = () => { + if (recordEventsRef.current) { + eventCountsRef.current.onEndReached++; + if (momentumEndedRef.current) { + report(); + } + } + }; + + const onStartReached = () => { + if (recordEventsRef.current) { + eventCountsRef.current.onStartReached++; + if (momentumEndedRef.current) { + report(); + } + } }; - const ref = useRef(null); - const onTest = () => { - const scrollResponder = ref?.current?.getScrollResponder(); - if (scrollResponder != null) { - scrollResponder.scrollToEnd(); + const onMomentumScrollEnd = () => { + if (recordEventsRef.current) { + eventCountsRef.current.onMomentumScrollEnd++; + momentumEndedRef.current = true; + report(); } }; + const onDragEvent = () => { + if (recordEventsRef.current) { + eventCountsRef.current.dragEvents++; + } + }; + + const scrollToEnd = () => { + recordEventsRef.current = true; + momentumEndedRef.current = false; + actionRef.current = 'end'; + attemptsRef.current++; + setOutput('running'); + listRef.current?.scrollToItem({ + animated: true, + item: SCROLL_TO_ITEM, + viewOffset: -ITEM_HEIGHT, + }); + }; + + const scrollToStart = () => { + momentumEndedRef.current = false; + actionRef.current = 'start'; + attemptsRef.current++; + setOutput('running'); + listRef.current?.scrollToOffset({animated: true, offset: 0}); + }; + + const exampleProps = { + initialNumToRender: 19, + onEndReached, + onEndReachedThreshold: 0.2, + onMomentumScrollEnd, + onScrollBeginDrag: onDragEvent, + onScrollEndDrag: onDragEvent, + onStartReached, + onStartReachedThreshold: 0.1, + windowSize: 2, + }; + return ( + onTest={scrollToEnd} + testLabel="Scroll to item"> +