From 9dc10a90298a077c9ee4b9507c53a003d71d9a77 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Sun, 14 Sep 2025 21:00:36 +0700 Subject: [PATCH 01/13] "Fix - Viewport does not return to highlighted message after returning from thread" --- .../FlatListWithScrollKey/index.ios.tsx | 125 ++++++++++++++++++ .../FlatList/FlatListWithScrollKey/index.tsx | 58 ++++++++ .../BaseInvertedFlatList/index.tsx | 76 ++--------- .../MoneyRequestReportActionsList.tsx | 28 +++- src/hooks/useFlatListScrollKey.ts | 97 ++++++++++++++ src/pages/home/report/ReportActionsList.tsx | 2 +- 6 files changed, 314 insertions(+), 72 deletions(-) create mode 100644 src/components/FlatList/FlatListWithScrollKey/index.ios.tsx create mode 100644 src/components/FlatList/FlatListWithScrollKey/index.tsx create mode 100644 src/hooks/useFlatListScrollKey.ts diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx new file mode 100644 index 0000000000000..87c70ace8f445 --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -0,0 +1,125 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react'; +import type {FlatListProps, LayoutChangeEvent, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; +import {InteractionManager} from 'react-native'; +import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; +import FlatList from '..'; + +type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { + data: T[]; + initialScrollKey?: string | null | undefined; + keyExtractor: (item: T, index: number) => string; + shouldEnableAutoScrollToTopThreshold?: boolean; + renderItem: ListRenderItem; +}; + +/** + * FlatList component that handles initial scroll key. + */ +function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { + const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, onLayout, onContentSizeChange, ...rest} = props; + const { + displayedData, + maintainVisibleContentPosition: maintainVisibleContentPositionProp, + handleStartReached, + isInitialData, + } = useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + inverted: false, + onStartReached, + shouldEnableAutoScrollToTopThreshold, + }); + const dataIndexDifference = data.length - displayedData.length; + + const handleRenderItem = useCallback( + ({item, index, separators}: ListRenderItemInfo) => { + // Adjust the index passed here so it matches the original data. + return renderItem({item, index: index + dataIndexDifference, separators}); + }, + [renderItem, dataIndexDifference], + ); + const [maintainVisibleContentPosition, setMaintainVisibleContentPosition] = useState(maintainVisibleContentPositionProp); + const flatListRef = useRef(null); + const flatListHeight = useRef(0); + const shouldScrollToEndRef = useRef(false); + + useEffect(() => { + if (isInitialData || initialScrollKey) { + return; + } + // On iOS, after the initial render is complete, if the ListHeaderComponent's height decreases shortly afterward, + // the maintainVisibleContentPosition mechanism on iOS keeps the viewport fixed and does not automatically scroll to fill the empty space above. + // Therefore, once rendering is complete and the highlighted item is kept in the viewport, we disable maintainVisibleContentPosition. + InteractionManager.runAfterInteractions(() => { + setMaintainVisibleContentPosition(undefined); + }); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isInitialData]); + + const onLayoutInner = useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + + flatListHeight.current = event.nativeEvent.layout.height; + }, + [onLayout], + ); + + const onContentSizeChangeInner = useCallback( + (w: number, h: number) => { + onContentSizeChange?.(w, h); + + if (!initialScrollKey) { + return; + } + // Since the ListHeaderComponent is only rendered after the data has finished rendering, iOS locks the entire current viewport. + // As a result, the viewport does not automatically scroll down to fill the gap at the bottom. + // We will check during the initial render (isInitialData === true). If the content height is less than the layout height, + // it means there is a gap at the bottom. + // Then, once the render is complete (isInitialData === false), we will manually scroll to the bottom. + if (shouldScrollToEndRef.current) { + InteractionManager.runAfterInteractions(() => { + flatListRef.current?.scrollToEnd(); + }); + shouldScrollToEndRef.current = false; + } + if (h < flatListHeight.current && isInitialData) { + shouldScrollToEndRef.current = true; + } + }, + [onContentSizeChange, isInitialData, initialScrollKey], + ); + + return ( + { + flatListRef.current = el; + if (typeof ref === 'function') { + ref(el); + } else if (ref) { + // eslint-disable-next-line no-param-reassign + ref.current = el; + } + }} + data={displayedData} + maintainVisibleContentPosition={maintainVisibleContentPosition} + onStartReached={handleStartReached} + renderItem={handleRenderItem} + keyExtractor={keyExtractor} + // Since ListHeaderComponent is always prioritized for rendering before the data, + // it will be rendered once the data has finished loading. + // This prevents an unnecessary empty space above the highlighted item. + ListHeaderComponent={!initialScrollKey || (!!initialScrollKey && !isInitialData) ? ListHeaderComponent : undefined} + onLayout={onLayoutInner} + onContentSizeChange={onContentSizeChangeInner} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + /> + ); +} + +FlatListWithScrollKey.displayName = 'FlatListWithScrollKey'; + +export default forwardRef(FlatListWithScrollKey); diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx new file mode 100644 index 0000000000000..45b1d8b101182 --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -0,0 +1,58 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback} from 'react'; +import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; +import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; +import FlatList from '..'; + +type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { + data: T[]; + initialScrollKey?: string | null | undefined; + keyExtractor: (item: T, index: number) => string; + shouldEnableAutoScrollToTopThreshold?: boolean; + renderItem: ListRenderItem; +}; + +/** + * FlatList component that handles initial scroll key. + */ +function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { + const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, ...rest} = props; + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData} = useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + inverted: false, + onStartReached, + shouldEnableAutoScrollToTopThreshold, + }); + const dataIndexDifference = data.length - displayedData.length; + + const handleRenderItem = useCallback( + ({item, index, separators}: ListRenderItemInfo) => { + // Adjust the index passed here so it matches the original data. + return renderItem({item, index: index + dataIndexDifference, separators}); + }, + [renderItem, dataIndexDifference], + ); + + return ( + + ); +} + +FlatListWithScrollKey.displayName = 'FlatListWithScrollKey'; + +export default forwardRef(FlatListWithScrollKey); diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 5cccc596a5222..bd1a9f72ac719 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,10 +1,8 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; +import React, {forwardRef, useCallback, useImperativeHandle, useRef} from 'react'; +import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import FlatList from '@components/FlatList'; -import usePrevious from '@hooks/usePrevious'; -import getInitialPaginationSize from './getInitialPaginationSize'; -import RenderTaskQueue from './RenderTaskQueue'; +import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { @@ -26,57 +24,18 @@ type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' initialScrollKey?: string | null; }; -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, ...rest} = props; - // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. - // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more - // previous items, until everything is rendered. We also progressively render new data that is added at the start of the - // list to make sure `maintainVisibleContentPosition` works as expected. - const [currentDataId, setCurrentDataId] = useState(() => { - if (initialScrollKey) { - return initialScrollKey; - } - return null; + const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId} = useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + inverted: true, + onStartReached, + shouldEnableAutoScrollToTopThreshold, }); - const [isInitialData, setIsInitialData] = useState(true); - const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const displayedData = useMemo(() => { - if (currentDataIndex <= 0) { - return data; - } - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : getInitialPaginationSize))); - }, [currentDataIndex, data, isInitialData]); - - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); const dataIndexDifference = data.length - displayedData.length; - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. - const renderQueue = useMemo(() => new RenderTaskQueue(), []); - useEffect(() => { - return () => { - renderQueue.cancel(); - }; - }, [renderQueue]); - - renderQueue.setHandler((info) => { - if (!isLoadingData) { - onStartReached?.(info); - } - setIsInitialData(false); - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); - }); - - const handleStartReached = useCallback( - (info: {distanceFromStart: number}) => { - renderQueue.add(info); - }, - [renderQueue], - ); - const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. @@ -85,19 +44,6 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa [renderItem, dataIndexDifference], ); - const maintainVisibleContentPosition = useMemo(() => { - const config: ScrollViewProps['maintainVisibleContentPosition'] = { - // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, - }; - - if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { - config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; - } - - return config; - }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - const listRef = useRef(null); useImperativeHandle(ref, () => { // If we're trying to scroll at the start of the list we need to make sure to @@ -143,6 +89,4 @@ BaseInvertedFlatList.displayName = 'BaseInvertedFlatList'; export default forwardRef(BaseInvertedFlatList); -export {AUTOSCROLL_TO_TOP_THRESHOLD}; - export type {BaseInvertedFlatListProps}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index dddcf67c941a0..7d555b651c945 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -10,12 +10,12 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; -import FlatList from '@components/FlatList'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; +import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -27,6 +27,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {queueExportSearchWithTemplate} from '@libs/actions/Search'; import DateUtils from '@libs/DateUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -52,6 +53,7 @@ import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter'; +import getInitialNumToRender from '@pages/home/report/getInitialNumReportActionsToRender'; import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer'; import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking'; @@ -71,7 +73,6 @@ import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyS */ const EmptyParentReportActionForTransactionThread = undefined; -const INITIAL_NUM_TO_RENDER = 20; // Amount of time to wait until all list items should be rendered and scrollToEnd will behave well const DELAY_FOR_SCROLLING_TO_END = 100; @@ -620,6 +621,22 @@ function MoneyRequestReportActionsList({ const isSelectAllChecked = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === transactionsWithoutPendingDelete.length; // Wrapped into useCallback to stabilize children re-renders const keyExtractor = useCallback((item: OnyxTypes.ReportAction) => item.reportActionID, []); + + const {windowHeight} = useWindowDimensions(); + /** + * Calculates the ideal number of report actions to render in the first render, based on the screen height and on + * the height of the smallest report action possible. + */ + const initialNumToRender = useMemo((): number | undefined => { + const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; + const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); + const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); + if (linkedReportActionID) { + return getInitialNumToRender(numToRender); + } + return numToRender || undefined; + }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); + return ( ) : ( - : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState removeClippedSubviews={false} + initialScrollKey={linkedReportActionID} /> )} diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts new file mode 100644 index 0000000000000..cf0fc31587e4d --- /dev/null +++ b/src/hooks/useFlatListScrollKey.ts @@ -0,0 +1,97 @@ +import {useCallback, useEffect, useMemo, useState} from 'react'; +import RenderTaskQueue from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; +import type {ScrollViewProps} from '@components/ScrollView'; +import getInitialPaginationSize from '@src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize'; +import usePrevious from './usePrevious'; + +type FlatListScrollKeyProps = { + data: T[]; + keyExtractor: (item: T, index: number) => string; + initialScrollKey: string | null | undefined; + inverted: boolean; + onStartReached?: ((info: {distanceFromStart: number}) => void) | null; + shouldEnableAutoScrollToTopThreshold?: boolean; +}; + +const AUTOSCROLL_TO_TOP_THRESHOLD = 250; + +export default function useFlatListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, inverted, shouldEnableAutoScrollToTopThreshold}: FlatListScrollKeyProps) { + // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. + // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more + // previous items, until everything is rendered. We also progressively render new data that is added at the start of the + // list to make sure `maintainVisibleContentPosition` works as expected. + const [currentDataId, setCurrentDataId] = useState(() => { + if (initialScrollKey) { + return initialScrollKey; + } + return null; + }); + const [isInitialData, setIsInitialData] = useState(true); + const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); + const displayedData = useMemo(() => { + if (currentDataIndex <= 0) { + return data; + } + // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. + // So we will need to return at least the last 2 elements in this case. + const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; + // We always render the list from the highlighted item to the end of the list because: + // - With an inverted FlatList, items are rendered from bottom to top, + // so the highlighted item stays at the bottom and within the visible viewport. + // - With a non-inverted (base) FlatList, items are rendered from top to bottom, + // making the highlighted item appear at the top of the list. + // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place + // as the rest of the items are appended. + return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); + }, [currentDataIndex, data, inverted, isInitialData]); + + const isLoadingData = data.length > displayedData.length; + const wasLoadingData = usePrevious(isLoadingData); + + // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. + const renderQueue = useMemo(() => new RenderTaskQueue(), []); + useEffect(() => { + return () => { + renderQueue.cancel(); + }; + }, [renderQueue]); + + renderQueue.setHandler((info) => { + if (!isLoadingData) { + onStartReached?.(info); + } + setIsInitialData(false); + const firstDisplayedItem = displayedData.at(0); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); + }); + + const handleStartReached = useCallback( + (info: {distanceFromStart: number}) => { + renderQueue.add(info); + }, + [renderQueue], + ); + + const maintainVisibleContentPosition = useMemo(() => { + const config: ScrollViewProps['maintainVisibleContentPosition'] = { + // This needs to be 1 to avoid using loading views as anchors. + minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, + }; + + if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { + config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; + } + + return config; + }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + + return { + handleStartReached, + setCurrentDataId, + displayedData, + maintainVisibleContentPosition, + isInitialData, + }; +} + +export {AUTOSCROLL_TO_TOP_THRESHOLD}; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index cf92c34966b39..82027ee72c0f8 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -6,10 +6,10 @@ import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent} from '@components/ActionSheetAwareScrollView'; import InvertedFlatList from '@components/InvertedFlatList'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; import {PersonalDetailsContext, usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useLocalize from '@hooks/useLocalize'; import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import useOnyx from '@hooks/useOnyx'; From 0d47c1a46b1f7606c075d56c4f88ff60e84ccf0d Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Sun, 14 Sep 2025 22:56:40 +0700 Subject: [PATCH 02/13] Fix - Viewport does not return to highlighted message after returning from thread --- src/hooks/useFlatListScrollKey.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index cf0fc31587e4d..7f4f256e0760f 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -26,8 +26,8 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro } return null; }); - const [isInitialData, setIsInitialData] = useState(true); const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); + const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); const displayedData = useMemo(() => { if (currentDataIndex <= 0) { return data; @@ -72,18 +72,31 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro [renderQueue], ); + useEffect(() => { + // In cases where the data is empty on the initial render, `handleStartReached` will never be triggered. + // We'll manually invoke it in this scenario. + if (inverted || data.length > 0) { + return; + } + handleStartReached({distanceFromStart: 0}); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + const maintainVisibleContentPosition = useMemo(() => { const config: ScrollViewProps['maintainVisibleContentPosition'] = { // This needs to be 1 to avoid using loading views as anchors. minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, }; + if (!inverted) { + config.minIndexForVisible = isInitialData ? 1 : 0; + } if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; } return config; - }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + }, [data.length, inverted, isInitialData, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); return { handleStartReached, From 490bb249afa9d436be3ec1653e9e9d6b569949c8 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Mon, 15 Sep 2025 10:51:36 +0700 Subject: [PATCH 03/13] Fix - Viewport does not return to highlighted message after returning from thread --- src/hooks/useFlatListScrollKey.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index 7f4f256e0760f..067fbedd30c7d 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -87,6 +87,12 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // This needs to be 1 to avoid using loading views as anchors. minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, }; + // Unlike an inverted FlatList, a non-inverted FlatList can have data.length === 0, + // which causes the initial value of `minIndexForVisible` to be 0. + // When data.length increases and `minIndexForVisible` updates accordingly, + // it can lead to a crash due to inconsistent rendering behavior. + // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift + // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. if (!inverted) { config.minIndexForVisible = isInitialData ? 1 : 0; } From 4781ee3017ec069d1d64669a3847cd7020f5352e Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 18 Sep 2025 11:49:26 +0700 Subject: [PATCH 04/13] Merge branch 'main' into fix/61550b --- src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 284d5f145e9ea..f4f0df3f8f027 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -25,8 +25,6 @@ type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' ref?: ForwardedRef; }; -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, ...rest} = props; const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId} = useFlatListScrollKey({ From 7de11bf9ed2299b315eff36d1bc76c90d7fe3354 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 18 Sep 2025 18:11:37 +0700 Subject: [PATCH 05/13] Fix - Viewport does not return to highlighted message after returning from thread --- .../FlatListWithScrollKey/index.ios.tsx | 25 ++-------- .../FlatList/FlatListWithScrollKey/index.tsx | 2 +- src/hooks/useFlatListScrollKey.ts | 47 ++++++++++++------- 3 files changed, 33 insertions(+), 41 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index 87c70ace8f445..4883331591fb3 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useRef} from 'react'; import type {FlatListProps, LayoutChangeEvent, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import {InteractionManager} from 'react-native'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; @@ -18,12 +18,7 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, onLayout, onContentSizeChange, ...rest} = props; - const { - displayedData, - maintainVisibleContentPosition: maintainVisibleContentPositionProp, - handleStartReached, - isInitialData, - } = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, @@ -40,24 +35,10 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For }, [renderItem, dataIndexDifference], ); - const [maintainVisibleContentPosition, setMaintainVisibleContentPosition] = useState(maintainVisibleContentPositionProp); const flatListRef = useRef(null); const flatListHeight = useRef(0); const shouldScrollToEndRef = useRef(false); - useEffect(() => { - if (isInitialData || initialScrollKey) { - return; - } - // On iOS, after the initial render is complete, if the ListHeaderComponent's height decreases shortly afterward, - // the maintainVisibleContentPosition mechanism on iOS keeps the viewport fixed and does not automatically scroll to fill the empty space above. - // Therefore, once rendering is complete and the highlighted item is kept in the viewport, we disable maintainVisibleContentPosition. - InteractionManager.runAfterInteractions(() => { - setMaintainVisibleContentPosition(undefined); - }); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isInitialData]); - const onLayoutInner = useCallback( (event: LayoutChangeEvent) => { onLayout?.(event); @@ -111,7 +92,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // Since ListHeaderComponent is always prioritized for rendering before the data, // it will be rendered once the data has finished loading. // This prevents an unnecessary empty space above the highlighted item. - ListHeaderComponent={!initialScrollKey || (!!initialScrollKey && !isInitialData) ? ListHeaderComponent : undefined} + ListHeaderComponent={!isInitialData ? ListHeaderComponent : undefined} onLayout={onLayoutInner} onContentSizeChange={onContentSizeChangeInner} // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index 45b1d8b101182..602cb774e3792 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -46,7 +46,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // Since ListHeaderComponent is always prioritized for rendering before the data, // it will be rendered once the data has finished loading. // This prevents an unnecessary empty space above the highlighted item. - ListHeaderComponent={!initialScrollKey || (!!initialScrollKey && !isInitialData) ? ListHeaderComponent : undefined} + ListHeaderComponent={!isInitialData ? ListHeaderComponent : undefined} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index 067fbedd30c7d..11bc07e81fb37 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -1,4 +1,5 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; +import {InteractionManager} from 'react-native'; import RenderTaskQueue from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; import type {ScrollViewProps} from '@components/ScrollView'; import getInitialPaginationSize from '@src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize'; @@ -26,15 +27,12 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro } return null; }); - const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); + const currentDataIndex = useMemo(() => (currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); const displayedData = useMemo(() => { if (currentDataIndex <= 0) { return data; } - // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. - // So we will need to return at least the last 2 elements in this case. - const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; // We always render the list from the highlighted item to the end of the list because: // - With an inverted FlatList, items are rendered from bottom to top, // so the highlighted item stays at the bottom and within the visible viewport. @@ -42,8 +40,8 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // making the highlighted item appear at the top of the list. // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place // as the rest of the items are appended. - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); - }, [currentDataIndex, data, inverted, isInitialData]); + return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : getInitialPaginationSize))); + }, [currentDataIndex, data, isInitialData]); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); @@ -62,7 +60,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro } setIsInitialData(false); const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : ''); }); const handleStartReached = useCallback( @@ -82,27 +80,40 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); + const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(false); const maintainVisibleContentPosition = useMemo(() => { + if (shouldPreserveVisibleContentPosition) { + return undefined; + } + + const dataLength = inverted ? data.length : displayedData.length; const config: ScrollViewProps['maintainVisibleContentPosition'] = { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, + minIndexForVisible: dataLength ? Math.min(1, dataLength - 1) : 0, }; - // Unlike an inverted FlatList, a non-inverted FlatList can have data.length === 0, - // which causes the initial value of `minIndexForVisible` to be 0. - // When data.length increases and `minIndexForVisible` updates accordingly, - // it can lead to a crash due to inconsistent rendering behavior. - // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift - // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. - if (!inverted) { - config.minIndexForVisible = isInitialData ? 1 : 0; - } if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; } return config; - }, [data.length, inverted, isInitialData, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + }, [shouldPreserveVisibleContentPosition, inverted, data.length, displayedData.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + + useEffect(() => { + if (inverted || isInitialData) { + return; + } + + // Unlike an inverted FlatList, a non-inverted FlatList can have data.length === 0, + // which causes the initial value of `minIndexForVisible` to be 0. + // When data.length increases and `minIndexForVisible` updates accordingly, + // it can lead to a crash due to inconsistent rendering behavior. + // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift + // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. + InteractionManager.runAfterInteractions(() => { + setShouldPreserveVisibleContentPosition(true); + }); + }, [inverted, isInitialData]); return { handleStartReached, From 0e3503c460ae2921f842825f30a2a8e89d602024 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Fri, 26 Sep 2025 12:26:04 +0700 Subject: [PATCH 06/13] Fix - Viewport does not return to highlighted message after returning from thread --- src/components/FlatList/FlatListWithScrollKey/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index 602cb774e3792..925057b9420db 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -16,7 +16,7 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr * FlatList component that handles initial scroll key. */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, ...rest} = props; + const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, contentContainerStyle, ...rest} = props; const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData} = useFlatListScrollKey({ data, keyExtractor, @@ -47,6 +47,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // it will be rendered once the data has finished loading. // This prevents an unnecessary empty space above the highlighted item. ListHeaderComponent={!isInitialData ? ListHeaderComponent : undefined} + contentContainerStyle={!isInitialData ? contentContainerStyle : undefined} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> From a7ad9cf7857e3c685297eb2edce8055f023a2c1f Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Tue, 30 Sep 2025 11:19:59 +0700 Subject: [PATCH 07/13] Fix - Viewport does not return to highlighted message after returning from thread --- .../FlatListWithScrollKey/index.ios.tsx | 15 ++++++++++++++- src/hooks/useFlatListScrollKey.ts | 18 ++++++++++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index 4883331591fb3..d6a88609d25db 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -17,7 +17,19 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr * FlatList component that handles initial scroll key. */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, onLayout, onContentSizeChange, ...rest} = props; + const { + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + renderItem, + keyExtractor, + ListHeaderComponent, + onLayout, + onContentSizeChange, + contentContainerStyle, + ...rest + } = props; const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData} = useFlatListScrollKey({ data, keyExtractor, @@ -93,6 +105,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // it will be rendered once the data has finished loading. // This prevents an unnecessary empty space above the highlighted item. ListHeaderComponent={!isInitialData ? ListHeaderComponent : undefined} + contentContainerStyle={!isInitialData ? contentContainerStyle : undefined} onLayout={onLayoutInner} onContentSizeChange={onContentSizeChangeInner} // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index 11bc07e81fb37..9f1ad129605e2 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -33,6 +33,9 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro if (currentDataIndex <= 0) { return data; } + // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. + // So we will need to return at least the last 2 elements in this case. + const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; // We always render the list from the highlighted item to the end of the list because: // - With an inverted FlatList, items are rendered from bottom to top, // so the highlighted item stays at the bottom and within the visible viewport. @@ -40,8 +43,8 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // making the highlighted item appear at the top of the list. // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place // as the rest of the items are appended. - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : getInitialPaginationSize))); - }, [currentDataIndex, data, isInitialData]); + return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); + }, [currentDataIndex, data, inverted, isInitialData]); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); @@ -80,16 +83,15 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); - const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(false); + const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); const maintainVisibleContentPosition = useMemo(() => { - if (shouldPreserveVisibleContentPosition) { + if (!shouldPreserveVisibleContentPosition) { return undefined; } - const dataLength = inverted ? data.length : displayedData.length; const config: ScrollViewProps['maintainVisibleContentPosition'] = { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: dataLength ? Math.min(1, dataLength - 1) : 0, + minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, }; if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { @@ -97,7 +99,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro } return config; - }, [shouldPreserveVisibleContentPosition, inverted, data.length, displayedData.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + }, [shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); useEffect(() => { if (inverted || isInitialData) { @@ -111,7 +113,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. InteractionManager.runAfterInteractions(() => { - setShouldPreserveVisibleContentPosition(true); + setShouldPreserveVisibleContentPosition(false); }); }, [inverted, isInitialData]); From 7408c3ea864059a11efedd5e64d3fe58ce057c6b Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 22 Oct 2025 22:50:04 +0700 Subject: [PATCH 08/13] Fix - Viewport does not return to highlighted message after returning from thread --- .../FlatListWithScrollKey/index.ios.tsx | 18 ++--- .../FlatList/FlatListWithScrollKey/index.tsx | 5 +- .../BaseInvertedFlatList/index.tsx | 31 +-------- src/hooks/useFlatListScrollKey.ts | 65 ++++++++++++++++--- 4 files changed, 66 insertions(+), 53 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index d6a88609d25db..74ce53243255c 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -30,13 +30,14 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For contentContainerStyle, ...rest } = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData} = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, listRef} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: false, onStartReached, shouldEnableAutoScrollToTopThreshold, + ref, }); const dataIndexDifference = data.length - displayedData.length; @@ -47,7 +48,6 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For }, [renderItem, dataIndexDifference], ); - const flatListRef = useRef(null); const flatListHeight = useRef(0); const shouldScrollToEndRef = useRef(false); @@ -74,7 +74,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // Then, once the render is complete (isInitialData === false), we will manually scroll to the bottom. if (shouldScrollToEndRef.current) { InteractionManager.runAfterInteractions(() => { - flatListRef.current?.scrollToEnd(); + listRef.current?.scrollToEnd(); }); shouldScrollToEndRef.current = false; } @@ -82,20 +82,12 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For shouldScrollToEndRef.current = true; } }, - [onContentSizeChange, isInitialData, initialScrollKey], + [onContentSizeChange, initialScrollKey, isInitialData, listRef], ); return ( { - flatListRef.current = el; - if (typeof ref === 'function') { - ref(el); - } else if (ref) { - // eslint-disable-next-line no-param-reassign - ref.current = el; - } - }} + ref={listRef} data={displayedData} maintainVisibleContentPosition={maintainVisibleContentPosition} onStartReached={handleStartReached} diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index 925057b9420db..807e52b3e2b27 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -17,13 +17,14 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, contentContainerStyle, ...rest} = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData} = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, listRef} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: false, onStartReached, shouldEnableAutoScrollToTopThreshold, + ref, }); const dataIndexDifference = data.length - displayedData.length; @@ -37,7 +38,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For return ( = Omit, 'data' | 'renderItem' function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, ...rest} = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId} = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, listRef} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: true, onStartReached, shouldEnableAutoScrollToTopThreshold, + ref, }); const dataIndexDifference = data.length - displayedData.length; @@ -46,32 +47,6 @@ function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) [renderItem, dataIndexDifference], ); - const listRef = useRef(null); - useImperativeHandle(ref, () => { - // If we're trying to scroll at the start of the list we need to make sure to - // render all items. - const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { - if (params.offset === 0) { - setCurrentDataId(null); - } - requestAnimationFrame(() => { - listRef.current?.scrollToOffset(params); - }); - }; - - return new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'scrollToOffset') { - return scrollToOffsetFn; - } - return listRef.current?.[prop as keyof RNFlatList]; - }, - }, - ) as RNFlatList; - }); - return ( = { inverted: boolean; onStartReached?: ((info: {distanceFromStart: number}) => void) | null; shouldEnableAutoScrollToTopThreshold?: boolean; + ref?: ForwardedRef; }; const AUTOSCROLL_TO_TOP_THRESHOLD = 250; -export default function useFlatListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, inverted, shouldEnableAutoScrollToTopThreshold}: FlatListScrollKeyProps) { +export default function useFlatListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, inverted, shouldEnableAutoScrollToTopThreshold, ref}: FlatListScrollKeyProps) { // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more // previous items, until everything is rendered. We also progressively render new data that is added at the start of the @@ -29,6 +32,8 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro }); const currentDataIndex = useMemo(() => (currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); + const [isQueueRendering, setIsQueueRendering] = useState(false); + const displayedData = useMemo(() => { if (currentDataIndex <= 0) { return data; @@ -50,7 +55,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro const wasLoadingData = usePrevious(isLoadingData); // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. - const renderQueue = useMemo(() => new RenderTaskQueue(), []); + const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); useEffect(() => { return () => { renderQueue.cancel(); @@ -63,7 +68,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro } setIsInitialData(false); const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : ''); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : null); }); const handleStartReached = useCallback( @@ -85,7 +90,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); const maintainVisibleContentPosition = useMemo(() => { - if (!shouldPreserveVisibleContentPosition) { + if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { return undefined; } @@ -99,10 +104,10 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro } return config; - }, [shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); useEffect(() => { - if (inverted || isInitialData) { + if (inverted || isInitialData || isQueueRendering) { return; } @@ -112,10 +117,49 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // it can lead to a crash due to inconsistent rendering behavior. // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. - InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { setShouldPreserveVisibleContentPosition(false); }); - }, [inverted, isInitialData]); + }, [inverted, isInitialData, isQueueRendering]); + + const listRef = useRef(null); + useImperativeHandle(ref, () => { + // If we're trying to scroll at the start of the list we need to make sure to + // render all items. + const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { + if (params.offset === 0) { + setCurrentDataId(null); + } + requestAnimationFrame(() => { + listRef.current?.scrollToOffset(params); + }); + }; + + const scrollToEndFn: RNFlatList['scrollToEnd'] = (params) => { + const scrollViewRef = listRef.current?.getNativeScrollRef(); + // Try to scroll on underlying scrollView if available, fallback to usual listRef + if (scrollViewRef && 'scrollToEnd' in scrollViewRef) { + (scrollViewRef as ScrollView).scrollToEnd({animated: false}); + return; + } + listRef.current?.scrollToEnd(params); + }; + + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'scrollToOffset') { + return scrollToOffsetFn; + } + if (prop === 'scrollToEnd') { + return scrollToEndFn; + } + return listRef.current?.[prop as keyof RNFlatList]; + }, + }, + ) as RNFlatList; + }); return { handleStartReached, @@ -123,6 +167,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro displayedData, maintainVisibleContentPosition, isInitialData, + listRef, }; } From b985dfb3f84df24cff95ff9435e71762b3bdc1f9 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 23 Oct 2025 09:17:09 +0700 Subject: [PATCH 09/13] Fix - Viewport does not return to highlighted message after returning from thread --- src/hooks/useFlatListScrollKey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index 6bb17986d026d..3bf9169e41c64 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -139,7 +139,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro const scrollViewRef = listRef.current?.getNativeScrollRef(); // Try to scroll on underlying scrollView if available, fallback to usual listRef if (scrollViewRef && 'scrollToEnd' in scrollViewRef) { - (scrollViewRef as ScrollView).scrollToEnd({animated: false}); + (scrollViewRef as ScrollView).scrollToEnd({animated: !!params?.animated}); return; } listRef.current?.scrollToEnd(params); From 86e73b48846a5eec2a93010ec972ee010fd56fb8 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Tue, 28 Oct 2025 18:10:12 +0700 Subject: [PATCH 10/13] "Fix - Viewport does not return to highlighted message after returning from thread" --- .../FlatList/FlatListWithScrollKey/index.tsx | 16 ++----- .../BaseInvertedFlatList/index.tsx | 16 ++----- src/hooks/useFlatListScrollKey.ts | 46 +++++++++++++++++-- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index 807e52b3e2b27..e72349b12d58b 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -1,6 +1,6 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback} from 'react'; -import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; +import React, {forwardRef} from 'react'; +import type {FlatListProps, ListRenderItem, FlatList as RNFlatList} from 'react-native'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; import FlatList from '..'; @@ -17,24 +17,16 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, contentContainerStyle, ...rest} = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, listRef} = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: false, onStartReached, shouldEnableAutoScrollToTopThreshold, + renderItem, ref, }); - const dataIndexDifference = data.length - displayedData.length; - - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [renderItem, dataIndexDifference], - ); return ( = Omit, 'data' | 'renderItem' function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, ...rest} = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, listRef} = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, handleRenderItem, listRef} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: true, onStartReached, shouldEnableAutoScrollToTopThreshold, + renderItem, ref, }); - const dataIndexDifference = data.length - displayedData.length; - - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [renderItem, dataIndexDifference], - ); return ( = { @@ -14,12 +18,22 @@ type FlatListScrollKeyProps = { inverted: boolean; onStartReached?: ((info: {distanceFromStart: number}) => void) | null; shouldEnableAutoScrollToTopThreshold?: boolean; + renderItem: ListRenderItem; ref?: ForwardedRef; }; const AUTOSCROLL_TO_TOP_THRESHOLD = 250; -export default function useFlatListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, inverted, shouldEnableAutoScrollToTopThreshold, ref}: FlatListScrollKeyProps) { +export default function useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + onStartReached, + inverted, + shouldEnableAutoScrollToTopThreshold, + renderItem, + ref, +}: FlatListScrollKeyProps) { // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more // previous items, until everything is rendered. We also progressively render new data that is added at the start of the @@ -34,7 +48,17 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); const [isQueueRendering, setIsQueueRendering] = useState(false); + // On the web platform, when data.length === 1, `maintainVisibleContentPosition` does not work. + // Therefore, we need to duplicate the data to ensure data.length >= 2 + const shouldDuplicateData = useMemo( + () => !inverted && data.length === 1 && isInitialData && (getPlatform() === CONST.PLATFORM.WEB || getPlatform() === CONST.PLATFORM.DESKTOP), + [data.length, inverted, isInitialData], + ); + const displayedData = useMemo(() => { + if (shouldDuplicateData) { + return [{...data.at(0), reportActionID: '0'} as T, ...data]; + } if (currentDataIndex <= 0) { return data; } @@ -49,10 +73,11 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place // as the rest of the items are appended. return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); - }, [currentDataIndex, data, inverted, isInitialData]); + }, [currentDataIndex, data, inverted, isInitialData, shouldDuplicateData]); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); + const dataIndexDifference = data.length - displayedData.length; // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); @@ -106,6 +131,18 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro return config; }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + const handleRenderItem = useCallback( + ({item, index, separators}: ListRenderItemInfo) => { + // Adjust the index passed here so it matches the original data. + if (shouldDuplicateData && index === 1) { + return React.createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); + } + + return renderItem({item, index: index + dataIndexDifference, separators}); + }, + [shouldDuplicateData, renderItem, dataIndexDifference], + ); + useEffect(() => { if (inverted || isInitialData || isQueueRendering) { return; @@ -167,6 +204,7 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro displayedData, maintainVisibleContentPosition, isInitialData, + handleRenderItem, listRef, }; } From ddef41bc2fb8ec0e63ff31aa47c44f5961232c03 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Tue, 28 Oct 2025 18:15:28 +0700 Subject: [PATCH 11/13] Fix - Viewport does not return to highlighted message after returning from thread --- .../FlatList/FlatListWithScrollKey/index.ios.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index 74ce53243255c..32cb950338404 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -1,6 +1,6 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useRef} from 'react'; -import type {FlatListProps, LayoutChangeEvent, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; +import type {FlatListProps, LayoutChangeEvent, ListRenderItem, FlatList as RNFlatList} from 'react-native'; import {InteractionManager} from 'react-native'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; import FlatList from '..'; @@ -30,24 +30,17 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For contentContainerStyle, ...rest } = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, listRef} = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: false, onStartReached, + renderItem, shouldEnableAutoScrollToTopThreshold, ref, }); - const dataIndexDifference = data.length - displayedData.length; - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [renderItem, dataIndexDifference], - ); const flatListHeight = useRef(0); const shouldScrollToEndRef = useRef(false); From 5f9fd572d407fad775796aae5c86e35d36b25fa6 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Mon, 17 Nov 2025 04:37:27 +0700 Subject: [PATCH 12/13] Fix - Viewport does not return to highlighted message after returning from thread --- .../FlatListWithScrollKey/index.ios.tsx | 26 ++++++++++++-- .../FlatList/FlatListWithScrollKey/index.tsx | 35 +++++++++++++++++-- src/hooks/useFlatListScrollKey.ts | 30 ++++++++++++++-- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index 32cb950338404..e38f1177e3897 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -1,7 +1,6 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useRef} from 'react'; import type {FlatListProps, LayoutChangeEvent, ListRenderItem, FlatList as RNFlatList} from 'react-native'; -import {InteractionManager} from 'react-native'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; import FlatList from '..'; @@ -28,6 +27,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For onLayout, onContentSizeChange, contentContainerStyle, + onViewableItemsChanged, ...rest } = props; const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ @@ -66,7 +66,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // it means there is a gap at the bottom. // Then, once the render is complete (isInitialData === false), we will manually scroll to the bottom. if (shouldScrollToEndRef.current) { - InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { listRef.current?.scrollToEnd(); }); shouldScrollToEndRef.current = false; @@ -78,6 +78,18 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For [onContentSizeChange, initialScrollKey, isInitialData, listRef], ); + const isLoadingData = useRef(true); + const isInitialDataRef = useRef(isInitialData); + useEffect(() => { + isInitialDataRef.current = isInitialData; + + if (!isLoadingData.current || data.length > displayedData.length) { + return; + } + + isLoadingData.current = false; + }, [data.length, displayedData.length, isInitialData]); + return ( (props: FlatListWithScrollKeyProps, ref: For onContentSizeChange={onContentSizeChangeInner} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} + onViewableItemsChanged={(info) => { + onViewableItemsChanged?.(info); + + if (info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0 || isInitialDataRef.current || !isLoadingData.current) { + return; + } + handleStartReached({distanceFromStart: 0}); + }} /> ); } diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index e72349b12d58b..1d3aaecc83b80 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef} from 'react'; +import React, {forwardRef, useEffect, useRef} from 'react'; import type {FlatListProps, ListRenderItem, FlatList as RNFlatList} from 'react-native'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; import FlatList from '..'; @@ -16,7 +16,18 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr * FlatList component that handles initial scroll key. */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, contentContainerStyle, ...rest} = props; + const { + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + renderItem, + keyExtractor, + ListHeaderComponent, + contentContainerStyle, + onViewableItemsChanged, + ...rest + } = props; const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ data, keyExtractor, @@ -28,6 +39,18 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For ref, }); + const isLoadingData = useRef(true); + const isInitialDataRef = useRef(isInitialData); + useEffect(() => { + isInitialDataRef.current = isInitialData; + + if (!isLoadingData.current || data.length > displayedData.length) { + return; + } + + isLoadingData.current = false; + }, [data.length, displayedData.length, isInitialData]); + return ( (props: FlatListWithScrollKeyProps, ref: For contentContainerStyle={!isInitialData ? contentContainerStyle : undefined} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} + onViewableItemsChanged={(info) => { + onViewableItemsChanged?.(info); + + if (info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0 || isInitialDataRef.current || !isLoadingData.current) { + return; + } + handleStartReached({distanceFromStart: 0}); + }} /> ); } diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index fb3e1b490b0b0..c82fbb63295b5 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -114,6 +114,16 @@ export default function useFlatListScrollKey({ }, []); const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); + + // For a non-inverted FlatList, `onViewableItemsChanged` is used to detect the first item in the viewport and trigger `handleStartReached`. + // Therefore, if the screen height is large enough, the first item may be visible immediately on initial render, + // causing the entire list to render and the highlighted item to be pushed out of the viewport. + // Therefore, we calculate the index of `initialScrollKey` within `displayedData` to determine `maintainVisibleContentPosition.minIndexForVisible`. + const currentDisplayedDataIndex = useMemo( + () => (inverted || displayedData.length === 0 || !initialScrollKey ? 0 : displayedData.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey)), + [displayedData, initialScrollKey, inverted, keyExtractor], + ); + const maintainVisibleContentPosition = useMemo(() => { if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { return undefined; @@ -121,7 +131,7 @@ export default function useFlatListScrollKey({ const config: ScrollViewProps['maintainVisibleContentPosition'] = { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, + minIndexForVisible: data.length && inverted ? Math.min(1, data.length - 1) : Math.min(getInitialPaginationSize, currentDisplayedDataIndex), }; if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { @@ -129,7 +139,18 @@ export default function useFlatListScrollKey({ } return config; - }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + }, [ + initialScrollKey, + isInitialData, + isQueueRendering, + shouldPreserveVisibleContentPosition, + data.length, + inverted, + shouldEnableAutoScrollToTopThreshold, + isLoadingData, + wasLoadingData, + currentDisplayedDataIndex, + ]); const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { @@ -154,9 +175,12 @@ export default function useFlatListScrollKey({ // it can lead to a crash due to inconsistent rendering behavior. // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. - requestAnimationFrame(() => { + const handle = requestAnimationFrame(() => { setShouldPreserveVisibleContentPosition(false); }); + return () => { + cancelAnimationFrame(handle); + }; }, [inverted, isInitialData, isQueueRendering]); const listRef = useRef(null); From e1d84558eb6516edcc8f942decee456425536567 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Mon, 17 Nov 2025 18:31:53 +0700 Subject: [PATCH 13/13] Fix - Viewport does not return to highlighted message after returning from thread --- .../BaseFlatListWithScrollKey.tsx | 95 +++++++++++++++++++ .../FlatListWithScrollKey/index.ios.tsx | 82 +++------------- .../FlatList/FlatListWithScrollKey/index.tsx | 75 ++------------- .../FlatList/FlatListWithScrollKey/types.ts | 14 +++ src/hooks/useFlatListScrollKey.ts | 30 +----- 5 files changed, 131 insertions(+), 165 deletions(-) create mode 100644 src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx create mode 100644 src/components/FlatList/FlatListWithScrollKey/types.ts diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx new file mode 100644 index 0000000000000..8574fb21a1b2f --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx @@ -0,0 +1,95 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useEffect, useRef} from 'react'; +import type {FlatList as RNFlatList} from 'react-native'; +import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; +import FlatList from '..'; +import type {BaseFlatListWithScrollKeyProps} from './types'; + +/** + * FlatList component that handles initial scroll key. + */ +function BaseFlatListWithScrollKey(props: BaseFlatListWithScrollKeyProps, ref: ForwardedRef) { + const { + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + renderItem, + keyExtractor, + onViewableItemsChanged, + onContentSizeChange, + onScrollBeginDrag, + onWheel, + onTouchStartCapture, + ...rest + } = props; + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + inverted: false, + onStartReached, + shouldEnableAutoScrollToTopThreshold, + renderItem, + ref, + }); + + const isLoadingData = useRef(true); + const isInitialDataRef = useRef(isInitialData); + // Determine whether the user has interacted with the FlatList, + // ensuring that handleStartReached is only triggered within onViewableItemsChanged after user interaction. + const hasUserInteractedRef = useRef(false); + + useEffect(() => { + isInitialDataRef.current = isInitialData; + + if (!isLoadingData.current || data.length > displayedData.length) { + return; + } + + isLoadingData.current = false; + }, [data.length, displayedData.length, isInitialData]); + + return ( + onContentSizeChange?.(width, height, isInitialData)} + onViewableItemsChanged={(info) => { + onViewableItemsChanged?.(info); + + if (!hasUserInteractedRef.current || isInitialDataRef.current || !isLoadingData.current || info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0) { + return; + } + handleStartReached({distanceFromStart: 0}); + }} + onScrollBeginDrag={(e) => { + onScrollBeginDrag?.(e); + hasUserInteractedRef.current = true; + }} + onWheel={(e) => { + onWheel?.(e); + hasUserInteractedRef.current = true; + }} + onTouchStartCapture={(e) => { + onTouchStartCapture?.(e); + hasUserInteractedRef.current = true; + }} + /> + ); +} + +BaseFlatListWithScrollKey.displayName = 'BaseFlatListWithScrollKey'; + +export default forwardRef(BaseFlatListWithScrollKey); diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index e38f1177e3897..ec38ec31e1c34 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -1,48 +1,19 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useRef} from 'react'; -import type {FlatListProps, LayoutChangeEvent, ListRenderItem, FlatList as RNFlatList} from 'react-native'; -import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; -import FlatList from '..'; - -type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { - data: T[]; - initialScrollKey?: string | null | undefined; - keyExtractor: (item: T, index: number) => string; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; -}; +import React, {forwardRef, useCallback, useRef} from 'react'; +import type {LayoutChangeEvent, FlatList as RNFlatList} from 'react-native'; +import mergeRefs from '@libs/mergeRefs'; +import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; +import type {FlatListWithScrollKeyProps} from './types'; /** * FlatList component that handles initial scroll key. */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const { - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor, - ListHeaderComponent, - onLayout, - onContentSizeChange, - contentContainerStyle, - onViewableItemsChanged, - ...rest - } = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - inverted: false, - onStartReached, - renderItem, - shouldEnableAutoScrollToTopThreshold, - ref, - }); + const {initialScrollKey, onLayout, onContentSizeChange, ...rest} = props; const flatListHeight = useRef(0); const shouldScrollToEndRef = useRef(false); + const listRef = useRef(null); const onLayoutInner = useCallback( (event: LayoutChangeEvent) => { @@ -54,7 +25,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For ); const onContentSizeChangeInner = useCallback( - (w: number, h: number) => { + (w: number, h: number, isInitialData?: boolean) => { onContentSizeChange?.(w, h); if (!initialScrollKey) { @@ -75,46 +46,17 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For shouldScrollToEndRef.current = true; } }, - [onContentSizeChange, initialScrollKey, isInitialData, listRef], + [onContentSizeChange, initialScrollKey], ); - const isLoadingData = useRef(true); - const isInitialDataRef = useRef(isInitialData); - useEffect(() => { - isInitialDataRef.current = isInitialData; - - if (!isLoadingData.current || data.length > displayedData.length) { - return; - } - - isLoadingData.current = false; - }, [data.length, displayedData.length, isInitialData]); - return ( - { - onViewableItemsChanged?.(info); - - if (info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0 || isInitialDataRef.current || !isLoadingData.current) { - return; - } - handleStartReached({distanceFromStart: 0}); - }} /> ); } diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index 1d3aaecc83b80..1f228527c541e 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -1,79 +1,18 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useEffect, useRef} from 'react'; -import type {FlatListProps, ListRenderItem, FlatList as RNFlatList} from 'react-native'; -import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; -import FlatList from '..'; - -type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { - data: T[]; - initialScrollKey?: string | null | undefined; - keyExtractor: (item: T, index: number) => string; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; -}; +import React, {forwardRef} from 'react'; +import type {FlatList as RNFlatList} from 'react-native'; +import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; +import type {FlatListWithScrollKeyProps} from './types'; /** * FlatList component that handles initial scroll key. */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const { - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor, - ListHeaderComponent, - contentContainerStyle, - onViewableItemsChanged, - ...rest - } = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - inverted: false, - onStartReached, - shouldEnableAutoScrollToTopThreshold, - renderItem, - ref, - }); - - const isLoadingData = useRef(true); - const isInitialDataRef = useRef(isInitialData); - useEffect(() => { - isInitialDataRef.current = isInitialData; - - if (!isLoadingData.current || data.length > displayedData.length) { - return; - } - - isLoadingData.current = false; - }, [data.length, displayedData.length, isInitialData]); - return ( - { - onViewableItemsChanged?.(info); - - if (info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0 || isInitialDataRef.current || !isLoadingData.current) { - return; - } - handleStartReached({distanceFromStart: 0}); - }} + {...props} /> ); } diff --git a/src/components/FlatList/FlatListWithScrollKey/types.ts b/src/components/FlatList/FlatListWithScrollKey/types.ts new file mode 100644 index 0000000000000..0c6211fdc4727 --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/types.ts @@ -0,0 +1,14 @@ +import type {FlatListProps, ListRenderItem} from 'react-native'; + +type BaseFlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex' | 'onContentSizeChange'> & { + data: T[]; + initialScrollKey?: string | null | undefined; + keyExtractor: (item: T, index: number) => string; + shouldEnableAutoScrollToTopThreshold?: boolean; + renderItem: ListRenderItem; + onContentSizeChange?: (contentWidth: number, contentHeight: number, isInitialData?: boolean) => void; +}; + +type FlatListWithScrollKeyProps = Omit, 'onContentSizeChange'> & Pick, 'onContentSizeChange'>; + +export type {FlatListWithScrollKeyProps, BaseFlatListWithScrollKeyProps}; diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index c82fbb63295b5..fb3e1b490b0b0 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -114,16 +114,6 @@ export default function useFlatListScrollKey({ }, []); const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); - - // For a non-inverted FlatList, `onViewableItemsChanged` is used to detect the first item in the viewport and trigger `handleStartReached`. - // Therefore, if the screen height is large enough, the first item may be visible immediately on initial render, - // causing the entire list to render and the highlighted item to be pushed out of the viewport. - // Therefore, we calculate the index of `initialScrollKey` within `displayedData` to determine `maintainVisibleContentPosition.minIndexForVisible`. - const currentDisplayedDataIndex = useMemo( - () => (inverted || displayedData.length === 0 || !initialScrollKey ? 0 : displayedData.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey)), - [displayedData, initialScrollKey, inverted, keyExtractor], - ); - const maintainVisibleContentPosition = useMemo(() => { if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { return undefined; @@ -131,7 +121,7 @@ export default function useFlatListScrollKey({ const config: ScrollViewProps['maintainVisibleContentPosition'] = { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length && inverted ? Math.min(1, data.length - 1) : Math.min(getInitialPaginationSize, currentDisplayedDataIndex), + minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, }; if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { @@ -139,18 +129,7 @@ export default function useFlatListScrollKey({ } return config; - }, [ - initialScrollKey, - isInitialData, - isQueueRendering, - shouldPreserveVisibleContentPosition, - data.length, - inverted, - shouldEnableAutoScrollToTopThreshold, - isLoadingData, - wasLoadingData, - currentDisplayedDataIndex, - ]); + }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { @@ -175,12 +154,9 @@ export default function useFlatListScrollKey({ // it can lead to a crash due to inconsistent rendering behavior. // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. - const handle = requestAnimationFrame(() => { + requestAnimationFrame(() => { setShouldPreserveVisibleContentPosition(false); }); - return () => { - cancelAnimationFrame(handle); - }; }, [inverted, isInitialData, isQueueRendering]); const listRef = useRef(null);