From 00b53d2f7f93c3a4926cc619681578809f4a52f3 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 24 Sep 2026 13:41:50 -0700 Subject: [PATCH] Fire VirtualizedList edge callbacks after programmatic scrolling 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. Track the item count when an edge callback is sent so internal measurement changes cannot emit duplicates while the list remains at the same edge; appending data or scrolling away re-arms the 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 | 68 ++++-- .../Lists/__tests__/VirtualizedList-test.js | 198 ++++++++++++++++++ 4 files changed, 357 insertions(+), 38 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"> +