Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9dc10a9
"Fix - Viewport does not return to highlighted message after returnin…
dmkt9 Sep 14, 2025
0d47c1a
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Sep 14, 2025
490bb24
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Sep 15, 2025
85af7af
Merge branch 'main' into fix/61550b
dmkt9 Sep 18, 2025
cf8b686
Merge branch 'main' into fix/61550b
dmkt9 Sep 18, 2025
4781ee3
Merge branch 'main' into fix/61550b
dmkt9 Sep 18, 2025
7de11bf
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Sep 18, 2025
9e1aca7
Merge branch 'main' into fix/61550b
dmkt9 Sep 26, 2025
0e3503c
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Sep 26, 2025
7dc6c03
Merge branch 'main' into fix/61550b
dmkt9 Sep 30, 2025
a7ad9cf
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Sep 30, 2025
a207d82
Merge branch 'main' into fix/61550b
dmkt9 Oct 7, 2025
47b3b00
Merge branch 'main' into fix/61550b
dmkt9 Oct 22, 2025
7408c3e
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Oct 22, 2025
b985dfb
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Oct 23, 2025
9b3ae7c
Merge branch 'main' into fix/61550b
dmkt9 Oct 28, 2025
86e73b4
"Fix - Viewport does not return to highlighted message after returnin…
dmkt9 Oct 28, 2025
ddef41b
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Oct 28, 2025
a2842cf
Merge branch 'main' into fix/61550b
dmkt9 Nov 16, 2025
5f9fd57
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Nov 16, 2025
e1d8455
Fix - Viewport does not return to highlighted message after returning…
dmkt9 Nov 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<T>(props: BaseFlatListWithScrollKeyProps<T>, ref: ForwardedRef<RNFlatList>) {
const {
shouldEnableAutoScrollToTopThreshold,
initialScrollKey,
data,
onStartReached,
renderItem,
keyExtractor,
onViewableItemsChanged,
onContentSizeChange,
onScrollBeginDrag,
onWheel,
onTouchStartCapture,
...rest
} = props;
const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey<T>({
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 (
<FlatList
ref={listRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
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={!isInitialData ? rest.ListHeaderComponent : undefined}
contentContainerStyle={!isInitialData ? rest.contentContainerStyle : undefined}
onContentSizeChange={(width, height) => 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);
66 changes: 66 additions & 0 deletions src/components/FlatList/FlatListWithScrollKey/index.ios.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {ForwardedRef} from 'react';
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<T>(props: FlatListWithScrollKeyProps<T>, ref: ForwardedRef<RNFlatList>) {
const {initialScrollKey, onLayout, onContentSizeChange, ...rest} = props;

const flatListHeight = useRef(0);
const shouldScrollToEndRef = useRef(false);
const listRef = useRef<RNFlatList>(null);

const onLayoutInner = useCallback(
(event: LayoutChangeEvent) => {
onLayout?.(event);

flatListHeight.current = event.nativeEvent.layout.height;
},
[onLayout],
);

const onContentSizeChangeInner = useCallback(
(w: number, h: number, isInitialData?: boolean) => {
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) {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd();
});
shouldScrollToEndRef.current = false;
}
if (h < flatListHeight.current && isInitialData) {
shouldScrollToEndRef.current = true;
}
},
[onContentSizeChange, initialScrollKey],
);

return (
<BaseFlatListWithScrollKey
ref={mergeRefs(ref, listRef)}
initialScrollKey={initialScrollKey}
onLayout={onLayoutInner}
onContentSizeChange={onContentSizeChangeInner}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
);
}

FlatListWithScrollKey.displayName = 'FlatListWithScrollKey';

export default forwardRef(FlatListWithScrollKey);
22 changes: 22 additions & 0 deletions src/components/FlatList/FlatListWithScrollKey/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {ForwardedRef} from 'react';
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<T>(props: FlatListWithScrollKeyProps<T>, ref: ForwardedRef<RNFlatList>) {
return (
<BaseFlatListWithScrollKey
ref={ref}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}

FlatListWithScrollKey.displayName = 'FlatListWithScrollKey';

export default forwardRef(FlatListWithScrollKey);
14 changes: 14 additions & 0 deletions src/components/FlatList/FlatListWithScrollKey/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type {FlatListProps, ListRenderItem} from 'react-native';

type BaseFlatListWithScrollKeyProps<T> = Omit<FlatListProps<T>, 'data' | 'initialScrollIndex' | 'onContentSizeChange'> & {
data: T[];
initialScrollKey?: string | null | undefined;
keyExtractor: (item: T, index: number) => string;
shouldEnableAutoScrollToTopThreshold?: boolean;
renderItem: ListRenderItem<T>;
onContentSizeChange?: (contentWidth: number, contentHeight: number, isInitialData?: boolean) => void;
};

type FlatListWithScrollKeyProps<T> = Omit<BaseFlatListWithScrollKeyProps<T>, 'onContentSizeChange'> & Pick<FlatListProps<T>, 'onContentSizeChange'>;

export type {FlatListWithScrollKeyProps, BaseFlatListWithScrollKeyProps};
119 changes: 12 additions & 107 deletions src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native';
import React from 'react';
import type {FlatListProps, ListRenderItem, 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<T>(item: T | {key: string} | {id: string}, index: number): string {
Expand All @@ -28,108 +26,17 @@ type BaseInvertedFlatListProps<T> = Omit<FlatListProps<T>, 'data' | 'renderItem'
shouldDisableVisibleContentPosition?: boolean;
};

const AUTOSCROLL_TO_TOP_THRESHOLD = 250;

function BaseInvertedFlatList<T>({ref, ...props}: BaseInvertedFlatListProps<T>) {
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 [isInitialData, setIsInitialData] = useState(true);
const [isQueueRendering, setIsQueueRendering] = useState(false);

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(setIsQueueRendering), []);
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<T>) => {
// Adjust the index passed here so it matches the original data.
return renderItem({item, index: index + dataIndexDifference, separators});
},
[renderItem, dataIndexDifference],
);

const maintainVisibleContentPosition = useMemo(() => {
if (!initialScrollKey && (!isInitialData || !isQueueRendering)) {
return undefined;
}

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;
}, [initialScrollKey, isInitialData, isQueueRendering, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]);

const listRef = useRef<RNFlatList | null>(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;
const {displayedData, maintainVisibleContentPosition, handleStartReached, handleRenderItem, listRef} = useFlatListScrollKey<T>({
data,
keyExtractor,
initialScrollKey,
inverted: true,
onStartReached,
shouldEnableAutoScrollToTopThreshold,
renderItem,
ref,
});

return (
Expand All @@ -151,6 +58,4 @@ BaseInvertedFlatList.displayName = 'BaseInvertedFlatList';

export default BaseInvertedFlatList;

export {AUTOSCROLL_TO_TOP_THRESHOLD};

export type {BaseInvertedFlatListProps};
Loading
Loading