import { forwardRef, isValidElement, memo, startTransition, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react' import { type FlatListProps, type ListRenderItemInfo, StyleSheet, View, type ViewProps, } from 'react-native' import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' import {batchedUpdates} from '#/lib/batchedUpdates' import {createRowObserver} from '#/lib/createRowObserver' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useScrollHandlers} from '#/lib/ScrollContext' import {addStyle} from '#/lib/styles' import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context' import {useTheme, web} from '#/alf' import * as Layout from '#/components/Layout' import {useAnalytics} from '#/analytics' export type ListMethods = { scrollToTop: () => void // Signature kept compatible with FlatList's scrollToOffset (the native // ListMethods type) so callers stay platform-agnostic. scrollToOffset: (options: {animated?: boolean | null; offset: number}) => void scrollToEnd: (options?: {animated?: boolean | null}) => void // Signature kept compatible with FlatList's scrollToIndex (the native // ListMethods type) so callers stay platform-agnostic. viewOffset is // accepted for parity but not currently used by the web implementation. scrollToIndex: (params: { animated?: boolean | null index: number viewOffset?: number viewPosition?: number }) => void } // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ListProps = Omit< FlatListProps, | 'onScroll' // Use ScrollContext instead. | 'refreshControl' // Pass refreshing and/or onRefresh instead. | 'contentOffset' // Pass headerOffset instead. > & { onScrolledDownChange?: (isScrolledDown: boolean) => void onScrollOffsetChange?: (offsetY: number) => void headerOffset?: number refreshing?: boolean onRefresh?: () => void onItemNearViewport?: (item: ItemT) => void onItemSeen?: (item: ItemT) => void desktopFixedHeight?: number | boolean // Web only prop to contain the scroll to the container rather than the window disableFullWindowScroll?: boolean /** * @deprecated Should be using Layout components */ sideBorders?: boolean } export type ListRef = React.RefObject const ON_ITEM_SEEN_WAIT_DURATION = 0.5e3 // when we consider post to be "seen" const ON_ITEM_SEEN_INTERSECTION_OPTS = { rootMargin: '-200px 0px -200px 0px', } // post must be 200px visible to be "seen" const ON_ITEM_NEAR_VIEWPORT_INTERSECTION_OPTS = { rootMargin: '800px 0px 800px 0px', } const PAGE_STARTED_AT = Date.now() const LARGE_LIST_MILESTONES = [100, 250, 500, 1000] as const const LONG_TASK_REPORT_INTERVAL = 60e3 // This is diagnostic telemetry, so a session-level sample is sufficient and // prevents popular feed surfaces from producing an event for every user. const ENABLE_WEB_LIST_TELEMETRY = Math.random() < 0.1 function ListImpl( { ListHeaderComponent, ListFooterComponent, ListEmptyComponent, disableFullWindowScroll: disableFullWindowScrollProp, contentContainerStyle, data, desktopFixedHeight, headerOffset, keyExtractor, refreshing: _unsupportedRefreshing, onStartReached, onStartReachedThreshold = 2, onEndReached, onEndReachedThreshold = 2, onRefresh: _unsupportedOnRefresh, onScrolledDownChange, onScrollOffsetChange, onContentSizeChange, onItemNearViewport, onItemSeen, renderItem, extraData, style, ...props }: ListProps, ref: React.Ref, ) { const contextScrollHandlers = useScrollHandlers() const {isWithinSplitView} = useIsWithinSplitView() const t = useTheme() // automatically disable full window scroll when within split view const disableFullWindowScroll = disableFullWindowScrollProp ?? isWithinSplitView const isEmpty = !data || data.length === 0 let headerComponent: React.JSX.Element | null = null if (ListHeaderComponent != null) { if (isValidElement(ListHeaderComponent)) { headerComponent = ListHeaderComponent } else { // @ts-expect-error Nah it's fine. headerComponent = } } let footerComponent: React.JSX.Element | null = null if (ListFooterComponent != null) { if (isValidElement(ListFooterComponent)) { footerComponent = ListFooterComponent } else { // @ts-expect-error Nah it's fine. footerComponent = } } let emptyComponent: React.JSX.Element | null = null if (ListEmptyComponent != null) { if (isValidElement(ListEmptyComponent)) { emptyComponent = ListEmptyComponent } else { // @ts-expect-error Nah it's fine. emptyComponent = } } if (headerOffset != null) { style = addStyle(style, { paddingTop: headerOffset, }) } const getScrollableNode = useCallback(() => { if (disableFullWindowScroll) { const element = nativeRef.current if (!element) return return { get scrollWidth() { return element.scrollWidth }, get scrollHeight() { return element.scrollHeight }, get clientWidth() { return element.clientWidth }, get clientHeight() { return element.clientHeight }, get scrollY() { return element.scrollTop }, get scrollX() { return element.scrollLeft }, scrollTo(options?: ScrollToOptions) { element.scrollTo(options) }, scrollBy(options: ScrollToOptions) { element.scrollBy(options) }, addEventListener( event: string, handler: EventListenerOrEventListenerObject, ) { element.addEventListener(event, handler) }, removeEventListener( event: string, handler: EventListenerOrEventListenerObject, ) { element.removeEventListener(event, handler) }, } } else { return { get scrollWidth() { return document.documentElement.scrollWidth }, get scrollHeight() { return document.documentElement.scrollHeight }, get clientWidth() { return window.innerWidth }, get clientHeight() { return window.innerHeight }, get scrollY() { return window.scrollY }, get scrollX() { return window.scrollX }, scrollTo(options: ScrollToOptions) { window.scrollTo(options) }, scrollBy(options: ScrollToOptions) { window.scrollBy(options) }, addEventListener( event: string, handler: EventListenerOrEventListenerObject, ) { window.addEventListener(event, handler) }, removeEventListener( event: string, handler: EventListenerOrEventListenerObject, ) { window.removeEventListener(event, handler) }, } } }, [disableFullWindowScroll]) const nativeRef = useRef(null) // Registry of item index -> row DOM node. The list renders header/footer and // visibility-detector siblings too, so we can't index into the container's // children directly; each Row registers its own node here keyed by index. const rowNodesRef = useRef>(new Map()) const registerRowNode = useCallback( (index: number, node: HTMLElement | null) => { if (node) { rowNodesRef.current.set(index, node) } else { rowNodesRef.current.delete(index) } }, [], ) useImperativeHandle( ref, () => ({ scrollToTop() { getScrollableNode()?.scrollTo({top: 0}) }, scrollToOffset({ animated, offset, }: { animated?: boolean | null offset: number }) { getScrollableNode()?.scrollTo({ left: 0, top: offset, behavior: animated ? 'smooth' : 'instant', }) }, scrollToEnd({animated = true} = {}) { const element = getScrollableNode() element?.scrollTo({ left: 0, top: element.scrollHeight, behavior: animated ? 'smooth' : 'instant', }) }, scrollToIndex({animated = true, index}) { const node = rowNodesRef.current.get(index) // scrollIntoView with block: 'center' roughly matches the caller's // viewPosition of 0.3 - not exact, but close enough and it respects // whichever element is the scroll container (window or nativeRef). node?.scrollIntoView({ block: 'center', behavior: animated ? 'smooth' : 'instant', }) }, }), [getScrollableNode], ) // --- onContentSizeChange, maintainVisibleContentPosition --- const containerRef = useRef(null) useResizeObserver(containerRef, onContentSizeChange) // --- onScroll --- const [isInsideVisibleTree, setIsInsideVisibleTree] = useState(false) useWebListTelemetry({ containerRef: nativeRef, enabled: isInsideVisibleTree, itemCount: data?.length ?? 0, rowNodesRef, }) const handleScroll = useNonReactiveCallback(() => { if (!isInsideVisibleTree) return const element = getScrollableNode() const offsetY = Math.max(0, element?.scrollY ?? 0) contextScrollHandlers.onScroll?.( { contentOffset: { x: Math.max(0, element?.scrollX ?? 0), y: offsetY, }, layoutMeasurement: { width: element?.clientWidth, height: element?.clientHeight, }, contentSize: { width: element?.scrollWidth, height: element?.scrollHeight, }, } as Exclude< ReanimatedScrollEvent, | 'velocity' | 'eventName' | 'zoomScale' | 'targetContentOffset' | 'contentInset' >, {}, ) onScrollOffsetChange?.(offsetY) }) useEffect(() => { if (!isInsideVisibleTree) { // Prevents hidden tabs from firing scroll events. // Only one list is expected to be firing these at a time. return } const element = getScrollableNode() element?.addEventListener('scroll', handleScroll) return () => { element?.removeEventListener('scroll', handleScroll) } }, [ isInsideVisibleTree, handleScroll, disableFullWindowScroll, getScrollableNode, ]) // --- onScrolledDownChange --- const isScrolledDown = useRef(false) function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) { const didScrollDown = !isAboveTheFold if (isScrolledDown.current !== didScrollDown) { isScrolledDown.current = didScrollDown startTransition(() => { onScrolledDownChange?.(didScrollDown) }) } } // --- onStartReached --- const onHeadVisibilityChange = useNonReactiveCallback( (isHeadVisible: boolean) => { if (isHeadVisible) { onStartReached?.({ distanceFromStart: onStartReachedThreshold || 0, }) } }, ) // --- onEndReached --- const onTailVisibilityChange = useNonReactiveCallback( (isTailVisible: boolean) => { if (isTailVisible) { onEndReached?.({ distanceFromEnd: onEndReachedThreshold || 0, }) } }, ) return ( >}> ) } type ChromiumPerformance = Performance & { memory?: { usedJSHeapSize: number jsHeapSizeLimit: number } } function getWebListDiagnostics( containerRef: React.RefObject, rowNodesRef: React.RefObject>, ) { const memory = (performance as ChromiumPerformance).memory return { renderedRowCount: rowNodesRef.current.size, contentHeight: containerRef.current?.scrollHeight ?? 0, sessionAgeMs: Date.now() - PAGE_STARTED_AT, ...(memory && { heapUsedBytes: memory.usedJSHeapSize, heapLimitBytes: memory.jsHeapSizeLimit, }), } } /** * APP-2859 diagnostic telemetry. Web List currently mounts every loaded row; * these events let us correlate list growth and browser main-thread stalls by * route without including any row content. Remove after the investigation. */ function useWebListTelemetry({ containerRef, enabled, itemCount, rowNodesRef, }: { containerRef: React.RefObject enabled: boolean itemCount: number rowNodesRef: React.RefObject> }) { const ax = useAnalytics() const reportedMilestones = useRef(new Set()) const itemCountRef = useRef(itemCount) itemCountRef.current = itemCount const isLargeList = itemCount >= LARGE_LIST_MILESTONES[0] useEffect(() => { if (!ENABLE_WEB_LIST_TELEMETRY || !enabled) return for (const milestone of LARGE_LIST_MILESTONES) { if (itemCount < milestone || reportedMilestones.current.has(milestone)) { continue } reportedMilestones.current.add(milestone) ax.metric('web:list:size', { itemCount, milestone, ...getWebListDiagnostics(containerRef, rowNodesRef), }) } }, [ax, containerRef, enabled, itemCount, rowNodesRef]) useEffect(() => { if ( !ENABLE_WEB_LIST_TELEMETRY || !enabled || !isLargeList || !('PerformanceObserver' in globalThis) ) { return } const stats = {taskCount: 0, totalDurationMs: 0, maxDurationMs: 0} const observer = new PerformanceObserver(list => { for (const entry of list.getEntries()) { stats.taskCount++ stats.totalDurationMs += entry.duration stats.maxDurationMs = Math.max(stats.maxDurationMs, entry.duration) } }) try { observer.observe({type: 'longtask'}) } catch { // Long Tasks API is not available in all browsers. return } const report = () => { if (stats.taskCount === 0) return ax.metric('web:list:longTasks', { itemCount: itemCountRef.current, taskCount: stats.taskCount, totalDurationMs: Math.round(stats.totalDurationMs), maxDurationMs: Math.round(stats.maxDurationMs), intervalMs: LONG_TASK_REPORT_INTERVAL, ...getWebListDiagnostics(containerRef, rowNodesRef), }) stats.taskCount = 0 stats.totalDurationMs = 0 stats.maxDurationMs = 0 } const interval = setInterval(report, LONG_TASK_REPORT_INTERVAL) return () => { clearInterval(interval) observer.disconnect() report() } }, [ax, containerRef, enabled, isLargeList, rowNodesRef]) } function EdgeVisibility({ root, topMargin, bottomMargin, containerRef, onVisibleChange, }: { root?: React.RefObject | null topMargin?: string bottomMargin?: string containerRef: React.RefObject onVisibleChange: (isVisible: boolean) => void }) { const [containerHeight, setContainerHeight] = useState(0) useResizeObserver(containerRef, (w, h) => { setContainerHeight(h) }) return ( ) } function useResizeObserver( ref: React.RefObject, onResize: undefined | ((w: number, h: number) => void), ) { const handleResize = useNonReactiveCallback(onResize ?? (() => {})) const isActive = !!onResize useEffect(() => { if (!isActive) { return } const resizeObserver = new ResizeObserver(entries => { batchedUpdates(() => { for (let entry of entries) { const rect = entry.contentRect handleResize(rect.width, rect.height) } }) }) const node = ref.current! resizeObserver.observe(node) return () => { resizeObserver.unobserve(node) } }, [handleResize, isActive, ref]) } const observeNearViewport = createRowObserver( ON_ITEM_NEAR_VIEWPORT_INTERSECTION_OPTS, ) const observeSeen = createRowObserver(ON_ITEM_SEEN_INTERSECTION_OPTS) let Row = function RowImpl({ item, index, renderItem, extraData: _unused, onItemNearViewport, onItemSeen, registerRowNode, }: { item: ItemT index: number renderItem: null | undefined | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemNearViewport: ((item: ItemT) => void) | undefined onItemSeen: ((item: ItemT) => void) | undefined registerRowNode: (index: number, node: HTMLElement | null) => void }): React.ReactNode { const rowRef = useRef(null) const intersectionTimeout = useRef | undefined>( undefined, ) const handleNearViewportIntersection = useNonReactiveCallback( (entries: IntersectionObserverEntry[]) => { if (entries.some(entry => entry.isIntersecting)) { onItemNearViewport?.(item) } }, ) useEffect(() => { if (!onItemNearViewport) return const row: Element | null = rowRef.current if (row) return observeNearViewport(row, handleNearViewportIntersection) }, [handleNearViewportIntersection, onItemNearViewport]) const handleIntersection = useNonReactiveCallback( (entries: IntersectionObserverEntry[]) => { batchedUpdates(() => { if (!onItemSeen) { return } entries.forEach(entry => { if (entry.isIntersecting) { if (!intersectionTimeout.current) { intersectionTimeout.current = setTimeout(() => { intersectionTimeout.current = undefined onItemSeen(item) }, ON_ITEM_SEEN_WAIT_DURATION) } } else { if (intersectionTimeout.current) { clearTimeout(intersectionTimeout.current) intersectionTimeout.current = undefined } } }) }) }, ) useEffect(() => { if (!onItemSeen) { return } const row: Element | null = rowRef.current const unobserve = row ? observeSeen(row, handleIntersection) : undefined return () => { unobserve?.() clearTimeout(intersectionTimeout.current) intersectionTimeout.current = undefined } }, [handleIntersection, onItemSeen]) // Register this row's DOM node so the list can scroll to it by index. useEffect(() => { const node: HTMLElement | null = rowRef.current registerRowNode(index, node) return () => { registerRowNode(index, null) } }, [index, registerRowNode]) if (!renderItem) { return null } return ( {renderItem({ item, index, separators: null as unknown as ListRenderItemInfo['separators'], })} ) } Row = memo(Row) as (props: { item: ItemT index: number renderItem: null | undefined | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemNearViewport: ((item: ItemT) => void) | undefined onItemSeen: ((item: ItemT) => void) | undefined registerRowNode: (index: number, node: HTMLElement | null) => void }) => React.ReactNode let Visibility = ({ root, topMargin = '0px', bottomMargin = '0px', onVisibleChange, style, }: { root?: React.RefObject | null topMargin?: string bottomMargin?: string onVisibleChange: (isVisible: boolean) => void style?: ViewProps['style'] }): React.ReactNode => { const tailRef = useRef(null) const isIntersecting = useRef(false) const handleIntersection = useNonReactiveCallback( (entries: IntersectionObserverEntry[]) => { batchedUpdates(() => { entries.forEach(entry => { if (entry.isIntersecting !== isIntersecting.current) { isIntersecting.current = entry.isIntersecting onVisibleChange(entry.isIntersecting) } }) }) }, ) useEffect(() => { const observer = new IntersectionObserver(handleIntersection, { root: root?.current ?? null, rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, }) const tail: Element | null = tailRef.current if (tail) { observer.observe(tail) } return () => { if (tail) { observer.unobserve(tail) } } }, [bottomMargin, handleIntersection, topMargin, root]) return ( ) } Visibility = memo(Visibility) export const List = memo(forwardRef(ListImpl)) as ( props: ListProps & {ref?: React.Ref}, ) => React.ReactElement // https://stackoverflow.com/questions/7944460/detect-safari-browser const styles = StyleSheet.create({ minHeightViewport: { minHeight: '100dvh', }, parentTreeVisibilityDetector: { // @ts-expect-error web only position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, }, aboveTheFoldDetector: { position: 'absolute', top: 0, left: 0, right: 0, // Bottom is dynamic. }, visibilityDetector: { pointerEvents: 'none', zIndex: -1, }, })