Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move logic to start report actions list at index to BaseInvertedFlatList #52149

Merged
merged 11 commits into from
Jan 17, 2025
Merged
18 changes: 18 additions & 0 deletions jest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,21 @@ jest.mock(
dispose() {}
},
);

jest.mock(
'@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue',
() =>
class SyncRenderTaskQueue {
private handler: (info: unknown) => void = () => {};

add(info: unknown) {
this.handler(info);
}

setHandler(handler: () => void) {
this.handler = handler;
}

cancel() {}
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const RENDER_DELAY = 500;

type RenderInfo = {
distanceFromStart: number;
};

class RenderTaskQueue {
private renderInfos: RenderInfo[] = [];

private isRendering = false;

private handler: (info: RenderInfo) => void = () => {};

private timeout: NodeJS.Timeout | null = null;

add(info: RenderInfo) {
this.renderInfos.push(info);

if (!this.isRendering) {
this.render();
}
}

setHandler(handler: (info: RenderInfo) => void) {
this.handler = handler;
}

cancel() {
if (this.timeout == null) {
return;
}
clearTimeout(this.timeout);
}

private render() {
const info = this.renderInfos.shift();
if (!info) {
this.isRendering = false;
return;
}
this.isRendering = true;

this.handler(info);

this.timeout = setTimeout(() => {
this.render();
}, RENDER_DELAY);
}
}

export default RenderTaskQueue;
91 changes: 85 additions & 6 deletions src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,102 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useMemo} from 'react';
import type {FlatListProps, FlatList as RNFlatList, ScrollViewProps} from 'react-native';
import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react';
import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native';
import FlatList from '@components/FlatList';
import usePrevious from '@hooks/usePrevious';
import getInitialPaginationSize from './getInitialPaginationSize';
import RenderTaskQueue from './RenderTaskQueue';

type BaseInvertedFlatListProps<T> = FlatListProps<T> & {
// 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 {
if (item != null) {
if (typeof item === 'object' && 'key' in item) {
return item.key;
}
if (typeof item === 'object' && 'id' in item) {
return item.id;
}
}
return String(index);
}

type BaseInvertedFlatListProps<T> = Omit<FlatListProps<T>, 'data' | 'renderItem' | 'initialScrollIndex'> & {
shouldEnableAutoScrollToTopThreshold?: boolean;
data: T[];
renderItem: ListRenderItem<T>;
initialScrollKey?: string | null;
};

const AUTOSCROLL_TO_TOP_THRESHOLD = 250;

function BaseInvertedFlatList<T>(props: BaseInvertedFlatListProps<T>, ref: ForwardedRef<RNFlatList>) {
const {shouldEnableAutoScrollToTopThreshold, ...rest} = props;
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 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<T>) => {
// Adjust the index passed here so it matches the original data.
return renderItem({item, index: index + dataIndexDifference, separators});
},
[renderItem, dataIndexDifference],
);

const maintainVisibleContentPosition = useMemo(() => {
const config: ScrollViewProps['maintainVisibleContentPosition'] = {
// This needs to be 1 to avoid using loading views as anchors.
minIndexForVisible: 1,
};

if (shouldEnableAutoScrollToTopThreshold) {
if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) {
config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD;
}

return config;
}, [shouldEnableAutoScrollToTopThreshold]);
}, [shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]);

return (
<FlatList
Expand All @@ -32,6 +105,10 @@ function BaseInvertedFlatList<T>(props: BaseInvertedFlatListProps<T>, ref: Forwa
ref={ref}
maintainVisibleContentPosition={maintainVisibleContentPosition}
inverted
data={displayedData}
onStartReached={handleStartReached}
renderItem={handleRenderItem}
keyExtractor={keyExtractor}
/>
);
}
Expand All @@ -41,3 +118,5 @@ BaseInvertedFlatList.displayName = 'BaseInvertedFlatList';
export default forwardRef(BaseInvertedFlatList);

export {AUTOSCROLL_TO_TOP_THRESHOLD};

export type {BaseInvertedFlatListProps};
5 changes: 3 additions & 2 deletions src/components/InvertedFlatList/index.native.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef} from 'react';
import type {FlatList, FlatListProps} from 'react-native';
import type {FlatList} from 'react-native';
import BaseInvertedFlatList from './BaseInvertedFlatList';
import type {BaseInvertedFlatListProps} from './BaseInvertedFlatList';
import CellRendererComponent from './CellRendererComponent';

function BaseInvertedFlatListWithRef<T>(props: FlatListProps<T>, ref: ForwardedRef<FlatList>) {
function BaseInvertedFlatListWithRef<T>(props: BaseInvertedFlatListProps<T>, ref: ForwardedRef<FlatList>) {
return (
<BaseInvertedFlatList
// eslint-disable-next-line react/jsx-props-no-spreading
Expand Down
9 changes: 3 additions & 6 deletions src/components/InvertedFlatList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useEffect, useRef} from 'react';
import type {FlatList, FlatListProps, NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import type {FlatList, NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import {DeviceEventEmitter} from 'react-native';
import CONST from '@src/CONST';
import BaseInvertedFlatList from './BaseInvertedFlatList';
import type {BaseInvertedFlatListProps} from './BaseInvertedFlatList';
import CellRendererComponent from './CellRendererComponent';

type InvertedFlatListProps<T> = FlatListProps<T> & {
shouldEnableAutoScrollToTopThreshold?: boolean;
};

// This is adapted from https://codesandbox.io/s/react-native-dsyse
// It's a HACK alert since FlatList has inverted scrolling on web
function InvertedFlatList<T>({onScroll: onScrollProp = () => {}, ...props}: InvertedFlatListProps<T>, ref: ForwardedRef<FlatList>) {
function InvertedFlatList<T>({onScroll: onScrollProp = () => {}, ...props}: BaseInvertedFlatListProps<T>, ref: ForwardedRef<FlatList>) {
const lastScrollEvent = useRef<number | null>(null);
const scrollEndTimeout = useRef<NodeJS.Timeout | null>(null);
const updateInProgress = useRef<boolean>(false);
Expand Down
Loading
Loading