From a02be2694a5aef3eb5695a1ba3796f118f222fdd Mon Sep 17 00:00:00 2001 From: Nihar Rupareliya <52943748+NiharR27@users.noreply.github.com> Date: Sat, 19 Oct 2024 18:17:03 +1000 Subject: [PATCH 1/5] Update index.tsx --- src/index.tsx | 119 ++++++++++++++++++++------------------------------ 1 file changed, 48 insertions(+), 71 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 14ddc45..fd58a3e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,36 +3,17 @@ import type { MutableRefObject } from 'react'; import type { ScrollViewProps, ViewProps } from 'react-native'; import { FlashList as ShopifyFlashList } from '@shopify/flash-list'; import type { FlashListProps } from '@shopify/flash-list'; +import { debounce } from 'lodash'; export type Props = Omit< FlashListProps, 'maintainVisibleContentPosition' > & { - /** - * Called once when the scroll position gets close to end of list. This must return a promise. - * You can `onEndReachedThreshold` as distance from end of list, when this function should be called. - */ onEndReached: () => Promise; - /** - * Called once when the scroll position gets close to begining of list. This must return a promise. - * You can `onStartReachedThreshold` as distance from beginning of list, when this function should be called. - */ onStartReached: () => Promise; - /** - * Enable autoScrollToTop. - * In chat type applications, you want to auto scroll to bottom, when new message comes it. - */ enableAutoscrollToTop?: boolean; - /** - * If `enableAutoscrollToTop` is true, the scroll threshold below which auto scrolling should occur. - */ autoscrollToTopThreshold?: number; - /** Scroll distance from beginning of list, when onStartReached should be called. */ onStartReachedThreshold?: number; - /** - * Scroll distance from end of list, when onStartReached should be called. - * Please note that this is different from onEndReachedThreshold of FlatList from react-native. - */ onEndReachedThreshold?: number; pageInfo: { hasNextPage: boolean; @@ -45,10 +26,9 @@ export type Props = Omit< }; /** - * Note: - * - `onEndReached` and `onStartReached` must return a promise. - * - `onEndReached` and `onStartReached` only get called once, per content length. - * - maintainVisibleContentPosition is fixed, and can't be modified through props. + * Bidirectional FlashList Component + * - Handles calling `onStartReached` and `onEndReached` for pagination. + * - Supports auto-scrolling to the top for chat-like applications. */ export const FlashList = React.forwardRef( ( @@ -72,7 +52,7 @@ export const FlashList = React.forwardRef( autoscrollToTopThreshold = 100, data, enableAutoscrollToTop, - onEndReached = () => Promise.resolve() as any, + onEndReached = () => Promise.resolve(), onEndReachedThreshold = 10, onScroll, onStartReached = () => Promise.resolve(), @@ -90,59 +70,58 @@ export const FlashList = React.forwardRef( const onStartReachedInPromise = useRef | null>(null); const onEndReachedInPromise = useRef | null>(null); - const maybeCallOnStartReached = useCallback(() => { - if (!hasPreviousPage || typeof onStartReached !== 'function') return; - - // If onStartReached has already been called for given data length, then ignore. - if (data?.length && onStartReachedTracker.current[data.length]) return; - - if (data?.length) onStartReachedTracker.current[data.length] = true; - - const p = () => { + const createTrackerCall = ( + hasPage: boolean, + tracker: MutableRefObject>, + dataLength: number | undefined, + handler: () => Promise, + inPromise: MutableRefObject | null> + ) => { + if (!hasPage || !handler || !dataLength || tracker.current[dataLength]) return; + + tracker.current[dataLength] = true; + + const callHandler = () => { return new Promise((resolve) => { - onStartReachedInPromise.current = null; + inPromise.current = null; resolve(); }); }; - if (onEndReachedInPromise.current) { - onEndReachedInPromise.current.finally(() => { - onStartReachedInPromise.current = onStartReached()?.then(p); + if (inPromise.current) { + inPromise.current.finally(() => { + inPromise.current = handler()?.then(callHandler); }); } else { - onStartReachedInPromise.current = onStartReached()?.then(p); + inPromise.current = handler()?.then(callHandler); } + }; + + const maybeCallOnStartReached = useCallback(() => { + createTrackerCall( + hasPreviousPage, + onStartReachedTracker, + data?.length, + onStartReached, + onStartReachedInPromise + ); }, [data?.length, onStartReached, hasPreviousPage]); const maybeCallOnEndReached = useCallback(() => { - if (!hasNextPage || typeof onEndReached !== 'function') return; - - // If onEndReached has already been called for given data length, then ignore. - if (data?.length && onEndReachedTracker.current[data.length]) return; - - if (data?.length) onEndReachedTracker.current[data.length] = true; - - const p = () => { - return new Promise((resolve) => { - onStartReachedInPromise.current = null; - resolve(); - }); - }; - - if (onStartReachedInPromise.current) { - onStartReachedInPromise.current.finally(() => { - onEndReachedInPromise.current = onEndReached()?.then(p); - }); - } else { - onEndReachedInPromise.current = onEndReached()?.then(p); - } + createTrackerCall( + hasNextPage, + onEndReachedTracker, + data?.length, + onEndReached, + onEndReachedInPromise + ); }, [data?.length, onEndReached, hasNextPage]); const checkScrollPosition = useCallback( (offset: number, visibleLength: number, contentLength: number) => { - const isScrollAtStart = offset < onStartReachedThreshold; + const isScrollAtStart = offset < onStartReachedThreshold!; const isScrollAtEnd = - contentLength - visibleLength - offset < onEndReachedThreshold; + contentLength - visibleLength - offset < onEndReachedThreshold!; if (isScrollAtStart) { maybeCallOnStartReached(); @@ -160,8 +139,7 @@ export const FlashList = React.forwardRef( ] ); - const handleScroll: ScrollViewProps['onScroll'] = (event) => { - // Call the parent onScroll handler, if provided. + const handleScroll: ScrollViewProps['onScroll'] = debounce((event) => { onScroll?.(event); const offset = event.nativeEvent.contentOffset.y; @@ -169,7 +147,7 @@ export const FlashList = React.forwardRef( const contentLength = event.nativeEvent.contentSize.height; checkScrollPosition(offset, visibleLength, contentLength); - }; + }, 100); const checkHeights = useCallback( (checkLayoutHeight: number, checkContentHeight: number) => { @@ -218,12 +196,11 @@ export const FlashList = React.forwardRef( onContentSizeChange={realOnContentSizeChange} onEndReached={null} onScroll={handleScroll} - maintainVisibleContentPosition={{ - autoscrollToTopThreshold: enableAutoscrollToTop - ? autoscrollToTopThreshold - : undefined, - minIndexForVisible: 1, - }} + maintainVisibleContentPosition={ + enableAutoscrollToTop + ? { autoscrollToTopThreshold, minIndexForVisible: 1 } + : undefined + } /> ); From 74465431273f3c6c7b70fd1d574a33daafc8e545 Mon Sep 17 00:00:00 2001 From: Nihar Rupareliya <52943748+NiharR27@users.noreply.github.com> Date: Sat, 3 May 2025 14:15:50 +1000 Subject: [PATCH 2/5] Update index.tsx --- src/index.tsx | 325 +++++++++++++++++++++++--------------------------- 1 file changed, 151 insertions(+), 174 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index fd58a3e..bae88d6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,18 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + forwardRef, +} from 'react'; import type { MutableRefObject } from 'react'; import type { ScrollViewProps, ViewProps } from 'react-native'; import { FlashList as ShopifyFlashList } from '@shopify/flash-list'; import type { FlashListProps } from '@shopify/flash-list'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; -export type Props = Omit< - FlashListProps, - 'maintainVisibleContentPosition' -> & { +export type Props = Omit, 'maintainVisibleContentPosition'> & { onEndReached: () => Promise; onStartReached: () => Promise; enableAutoscrollToTop?: boolean; @@ -19,194 +23,167 @@ export type Props = Omit< hasNextPage: boolean; hasPreviousPage: boolean; }; - ref?: - | ((instance: ShopifyFlashList | null) => void) - | MutableRefObject | null> - | null; }; -/** - * Bidirectional FlashList Component - * - Handles calling `onStartReached` and `onEndReached` for pagination. - * - Supports auto-scrolling to the top for chat-like applications. - */ -export const FlashList = React.forwardRef( - ( - props: Props, - ref: - | ((instance: ShopifyFlashList | null) => void) - | MutableRefObject | null> - | null - ) => { - const { - pageInfo = { - hasNextPage: false, - hasPreviousPage: false, - }, - ...restProps - } = props; - - const { hasNextPage, hasPreviousPage } = pageInfo; - - const { - autoscrollToTopThreshold = 100, - data, - enableAutoscrollToTop, - onEndReached = () => Promise.resolve(), - onEndReachedThreshold = 10, - onScroll, - onStartReached = () => Promise.resolve(), - onStartReachedThreshold = 10, - onLayout, - onContentSizeChange, - } = restProps; - - const [contentHeight, setContentHeight] = useState(0); - const [layoutHeight, setLayoutHeight] = useState(0); - - const onStartReachedTracker = useRef>({}); - const onEndReachedTracker = useRef>({}); - - const onStartReachedInPromise = useRef | null>(null); - const onEndReachedInPromise = useRef | null>(null); - - const createTrackerCall = ( +type BidirectionalFlashListType = ( + props: Props & { ref?: React.Ref> } +) => React.ReactElement; + +const BidirectionalFlashList = forwardRef(( + props: Props, + ref: React.Ref> +) => { + const { + pageInfo: { hasNextPage, hasPreviousPage }, + autoscrollToTopThreshold = 100, + data, + enableAutoscrollToTop, + onEndReached, + onEndReachedThreshold = 10, + onScroll, + onStartReached, + onStartReachedThreshold = 10, + onLayout, + onContentSizeChange, + ...restProps + } = props; + + const [contentHeight, setContentHeight] = useState(0); + const [layoutHeight, setLayoutHeight] = useState(0); + + const onStartReachedTracker = useRef>({}); + const onEndReachedTracker = useRef>({}); + + const onStartReachedInPromise = useRef | null>(null); + const onEndReachedInPromise = useRef | null>(null); + + const createTrackerCall = useCallback( + ( hasPage: boolean, tracker: MutableRefObject>, dataLength: number | undefined, handler: () => Promise, inPromise: MutableRefObject | null> ) => { - if (!hasPage || !handler || !dataLength || tracker.current[dataLength]) return; - + if (!hasPage || dataLength == null || tracker.current[dataLength]) { + return; + } tracker.current[dataLength] = true; - + const callHandler = () => { - return new Promise((resolve) => { - inPromise.current = null; - resolve(); - }); + inPromise.current = null; + }; + + const call = async () => { + await handler(); + callHandler(); }; if (inPromise.current) { inPromise.current.finally(() => { - inPromise.current = handler()?.then(callHandler); + inPromise.current = call(); }); } else { - inPromise.current = handler()?.then(callHandler); + inPromise.current = call(); } - }; - - const maybeCallOnStartReached = useCallback(() => { - createTrackerCall( - hasPreviousPage, - onStartReachedTracker, - data?.length, - onStartReached, - onStartReachedInPromise - ); - }, [data?.length, onStartReached, hasPreviousPage]); - - const maybeCallOnEndReached = useCallback(() => { - createTrackerCall( - hasNextPage, - onEndReachedTracker, - data?.length, - onEndReached, - onEndReachedInPromise - ); - }, [data?.length, onEndReached, hasNextPage]); - - const checkScrollPosition = useCallback( - (offset: number, visibleLength: number, contentLength: number) => { - const isScrollAtStart = offset < onStartReachedThreshold!; - const isScrollAtEnd = - contentLength - visibleLength - offset < onEndReachedThreshold!; - - if (isScrollAtStart) { - maybeCallOnStartReached(); - } - - if (isScrollAtEnd) { - maybeCallOnEndReached(); - } - }, - [ - maybeCallOnEndReached, - maybeCallOnStartReached, - onEndReachedThreshold, - onStartReachedThreshold, - ] + }, + [] + ); + + const maybeCallOnStartReached = useCallback(() => { + createTrackerCall( + hasPreviousPage, + onStartReachedTracker, + data?.length, + onStartReached, + onStartReachedInPromise ); - - const handleScroll: ScrollViewProps['onScroll'] = debounce((event) => { - onScroll?.(event); - - const offset = event.nativeEvent.contentOffset.y; - const visibleLength = event.nativeEvent.layoutMeasurement.height; - const contentLength = event.nativeEvent.contentSize.height; - - checkScrollPosition(offset, visibleLength, contentLength); - }, 100); - - const checkHeights = useCallback( - (checkLayoutHeight: number, checkContentHeight: number) => { - if (checkLayoutHeight >= checkContentHeight) { - checkScrollPosition(0, checkLayoutHeight, checkContentHeight); - } - }, - [checkScrollPosition] - ); - - const realOnContentSizeChange = useCallback( - (w: number, newContentHeight: number) => { - if (onContentSizeChange) { - onContentSizeChange(w, newContentHeight); - } - setContentHeight(newContentHeight); - checkHeights(layoutHeight, newContentHeight); - }, - [checkHeights, layoutHeight, onContentSizeChange] + }, [data?.length, hasPreviousPage, onStartReached, createTrackerCall]); + + const maybeCallOnEndReached = useCallback(() => { + createTrackerCall( + hasNextPage, + onEndReachedTracker, + data?.length, + onEndReached, + onEndReachedInPromise ); + }, [data?.length, hasNextPage, onEndReached, createTrackerCall]); - const onLayoutSizeChange: ViewProps['onLayout'] = useCallback( - (e) => { - if (onLayout) { - onLayout(e); - } - + const checkScrollPosition = useCallback( + (offset: number, visibleLength: number, contentLength: number) => { + if (offset < onStartReachedThreshold) { + maybeCallOnStartReached(); + } + if (contentLength - visibleLength - offset < onEndReachedThreshold) { + maybeCallOnEndReached(); + } + }, + [maybeCallOnStartReached, maybeCallOnEndReached, onStartReachedThreshold, onEndReachedThreshold] + ); + + const handleScroll = useMemo( + () => + debounce((event: any) => { + onScroll?.(event); const { - nativeEvent: { - layout: { height }, - }, - } = e; - setLayoutHeight(height); - checkHeights(height, contentHeight); - }, - [checkHeights, contentHeight, onLayout] - ); + contentOffset: { y: offset }, + layoutMeasurement: { height: visibleLength }, + contentSize: { height: contentLength }, + } = event.nativeEvent; + checkScrollPosition(offset, visibleLength, contentLength); + }, 100), + [onScroll, checkScrollPosition] + ); + + useEffect(() => { + return () => { + handleScroll.cancel(); + }; + }, [handleScroll]); - return ( - <> - - {...restProps} - ref={ref} - progressViewOffset={50} - onLayout={onLayoutSizeChange} - onContentSizeChange={realOnContentSizeChange} - onEndReached={null} - onScroll={handleScroll} - maintainVisibleContentPosition={ - enableAutoscrollToTop - ? { autoscrollToTopThreshold, minIndexForVisible: 1 } - : undefined - } - /> - - ); - } -) as unknown as BidirectionalFlashListType; + const checkHeights = useCallback( + (layoutH: number, contentH: number) => { + if (layoutH >= contentH) { + checkScrollPosition(0, layoutH, contentH); + } + }, + [checkScrollPosition] + ); + + const realOnContentSizeChange = useCallback( + (w: number, newContentHeight: number) => { + onContentSizeChange?.(w, newContentHeight); + setContentHeight(newContentHeight); + checkHeights(layoutHeight, newContentHeight); + }, + [onContentSizeChange, layoutHeight, checkHeights] + ); + + const onLayoutSizeChange: ViewProps['onLayout'] = useCallback( + (e) => { + onLayout?.(e); + setLayoutHeight(e.nativeEvent.layout.height); + checkHeights(e.nativeEvent.layout.height, contentHeight); + }, + [onLayout, contentHeight, checkHeights] + ); + + return ( + + {...restProps} + ref={ref} + progressViewOffset={50} + onLayout={onLayoutSizeChange} + onContentSizeChange={realOnContentSizeChange} + onScroll={handleScroll} + maintainVisibleContentPosition={ + enableAutoscrollToTop + ? { autoscrollToTopThreshold, minIndexForVisible: 1 } + : undefined + } + /> + ); +}) as unknown as BidirectionalFlashListType; -type BidirectionalFlashListType = ( - props: Props -) => React.ReactElement; +export default BidirectionalFlashList; From 9684758d935b09b16f6884a324f3bfdcee20a0bb Mon Sep 17 00:00:00 2001 From: Nihar Rupareliya <52943748+NiharR27@users.noreply.github.com> Date: Sat, 3 May 2025 14:19:54 +1000 Subject: [PATCH 3/5] Update src/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index bae88d6..a6a258e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -124,7 +124,7 @@ const BidirectionalFlashList = forwardRef(( const handleScroll = useMemo( () => - debounce((event: any) => { + debounce((event: NativeSyntheticEvent) => { onScroll?.(event); const { contentOffset: { y: offset }, From 394870508670035ce53d36393ab2c6924abd5649 Mon Sep 17 00:00:00 2001 From: Nihar Rupareliya <52943748+NiharR27@users.noreply.github.com> Date: Sat, 3 May 2025 14:21:25 +1000 Subject: [PATCH 4/5] Update src/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/index.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index a6a258e..af38e10 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -57,6 +57,16 @@ const BidirectionalFlashList = forwardRef(( const onStartReachedInPromise = useRef | null>(null); const onEndReachedInPromise = useRef | null>(null); + /** + * Handles tracking and execution of pagination events (e.g., onStartReached, onEndReached). + * Ensures that the handler is called only once per data length and manages concurrent calls. + * + * @param hasPage - Indicates if there are more pages to load (next or previous). + * @param tracker - A ref object to track whether the handler has been called for a specific data length. + * @param dataLength - The current length of the data array. + * @param handler - The function to execute when the pagination event is triggered. + * @param inPromise - A ref object to manage the state of the ongoing handler promise. + */ const createTrackerCall = useCallback( ( hasPage: boolean, From 81b231ff1d1fe7bcc20ec0d848e1ca2fef89fa55 Mon Sep 17 00:00:00 2001 From: Nihar Rupareliya <52943748+NiharR27@users.noreply.github.com> Date: Sat, 3 May 2025 14:23:26 +1000 Subject: [PATCH 5/5] Update src/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index af38e10..3a38312 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -85,8 +85,13 @@ const BidirectionalFlashList = forwardRef(( }; const call = async () => { - await handler(); - callHandler(); + try { + await handler(); + } catch (error) { + console.error("Error in handler:", error); + } finally { + callHandler(); + } }; if (inPromise.current) {