diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch new file mode 100644 index 000000000000..971a05fa078b --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch @@ -0,0 +1,104 @@ +diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +index fa786bf..014eb62 100644 +--- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts ++++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +@@ -99,6 +99,15 @@ export interface FlashListProps extends Omit | React.ExoticComponent | React.FC; ++ /** ++ * When set, the list uses this as its visible window size instead of measuring its outer container. ++ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll ++ * events), where the outer container is as tall as the full content and can't be used as the viewport. ++ */ ++ overrideWindowSize?: { ++ width: number; ++ height: number; ++ }; + /** + * Draw distance for advanced rendering (in dp/px) + */ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index ef4daf2..063fd1a 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -28,7 +28,7 @@ import { RenderTimeTracker } from "./helpers/RenderTimeTracker"; + const RecyclerViewComponent = (props, ref) => { + var _a, _b, _c, _d; + // Destructure props and initialize refs +- const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props; ++ const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, overrideWindowSize, ...rest } = props; + const [renderTimeTracker] = useState(() => new RenderTimeTracker()); + renderTimeTracker.startTracking(); + // Sticky header config +@@ -147,12 +147,15 @@ const RecyclerViewComponent = (props, ref) => { + : horizontal + ? firstChildViewLayout.x + : firstChildViewLayout.y; ++ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full ++ // content) declare its visible window; everything else still uses the real measurement. ++ const windowSize = overrideWindowSize !== null && overrideWindowSize !== void 0 ? overrideWindowSize : outerViewSize; + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams({ +- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, ++ width: horizontal ? windowSize.width : firstChildViewLayout.width, + height: horizontal + ? firstChildViewLayout.height +- : outerViewSize.height, ++ : windowSize.height, + }, isHorizontalRTL && recyclerViewManager.hasLayout() + ? firstItemOffset - + recyclerViewManager.getChildContainerDimensions().width +diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts +index 76dd0c8..5a5c0f5 100644 +--- a/node_modules/@shopify/flash-list/src/FlashListProps.ts ++++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts +@@ -160,6 +160,16 @@ export interface FlashListProps + | React.ExoticComponent + | React.FC; + ++ /** ++ * When set, the list uses this as its visible window size instead of measuring its outer container. ++ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll ++ * events), where the outer container is as tall as the full content and can't be used as the viewport. ++ */ ++ overrideWindowSize?: { ++ width: number; ++ height: number; ++ }; ++ + /** + * Draw distance for advanced rendering (in dp/px) + */ +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index bc27739..a7829d5 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -90,6 +90,7 @@ const RecyclerViewComponent = ( + onChangeStickyIndex, + stickyHeaderConfig, + inverted, ++ overrideWindowSize, + ...rest + } = props; + +@@ -265,13 +266,17 @@ const RecyclerViewComponent = ( + ? firstChildViewLayout.x + : firstChildViewLayout.y; + ++ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full ++ // content) declare its visible window; everything else still uses the real measurement. ++ const windowSize = overrideWindowSize ?? outerViewSize; ++ + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams( + { +- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, ++ width: horizontal ? windowSize.width : firstChildViewLayout.width, + height: horizontal + ? firstChildViewLayout.height +- : outerViewSize.height, ++ : windowSize.height, + }, + isHorizontalRTL && recyclerViewManager.hasLayout() + ? firstItemOffset - diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch new file mode 100644 index 000000000000..5d59a58de2ff --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch @@ -0,0 +1,358 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index 063fd1a..c37ff3a 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -349,8 +349,27 @@ const RecyclerViewComponent = (props, ref) => { + recyclerViewContext.layout(); + } + }, [recyclerViewContext, recyclerViewManager]); ++ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component ++ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep ++ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass ++ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, ++ // so a header resize cannot shift item positions. ++ const lastHeaderSizeRef = useRef(-1); ++ const onHeaderLayout = useCallback((event) => { ++ if (inverted) { ++ return; ++ } ++ const headerSize = horizontal ++ ? event.nativeEvent.layout.width ++ : event.nativeEvent.layout.height; ++ if (lastHeaderSizeRef.current >= 0 && ++ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize)) { ++ recyclerViewContext.layout(); ++ } ++ lastHeaderSizeRef.current = headerSize; ++ }, [horizontal, inverted, recyclerViewContext]); + // Get secondary props and components +- const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props); ++ const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props, onHeaderLayout); + if (!recyclerViewManager.getIsFirstLayoutComplete() && + recyclerViewManager.getDataLength() > 0) { + parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.markChildLayoutAsPending(recyclerViewId); +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +index 40bdddb..c505e59 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +@@ -51,6 +51,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + // Track the first visible item for maintaining scroll position + const firstVisibleItemKey = useRef(undefined); + const firstVisibleItemLayout = useRef(undefined); ++ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are ++ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta ++ // must be captured separately or offset correction is blind to it. ++ const firstVisibleItemFirstItemOffset = useRef(0); + // Queue to store callbacks that should be executed after scroll offset updates + const pendingScrollCallbacks = useRef([]); + // Handle initial scroll position when the list first loads +@@ -82,6 +86,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + recyclerViewManager.hasStableDataKeys() && + recyclerViewManager.getDataLength() > 0 && + recyclerViewManager.shouldMaintainVisibleContentPosition()) { ++ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the ++ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can ++ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking ++ // the viewport away from what the user is looking at. The header's own top never moves, so the correct ++ // correction while it is the anchor is none — drop the tracked item instead. ++ if (recyclerViewManager.getAbsoluteLastScrollOffset() < ++ recyclerViewManager.firstItemOffset) { ++ firstVisibleItemKey.current = undefined; ++ return; ++ } + // Update the tracked first visible item + const firstVisibleIndex = Math.max(0, recyclerViewManager.computeVisibleIndices().startIndex); + if (firstVisibleIndex !== undefined && firstVisibleIndex >= 0) { +@@ -90,6 +104,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(firstVisibleIndex), + }; ++ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; + } + } + }, [recyclerViewManager]); +@@ -128,15 +143,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + : undefined); + if (currentIndexOfFirstVisibleItem !== undefined && + currentIndexOfFirstVisibleItem >= 0) { +- // Calculate the difference in position and apply the offset +- const diff = horizontal ++ // Calculate the difference in position and apply the offset. Item layouts are header-relative, ++ // so a ListHeaderComponent resize shifts every item on screen by the same amount without ++ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. ++ const layoutDiff = horizontal + ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - + firstVisibleItemLayout.current.x + : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - + firstVisibleItemLayout.current.y; ++ const diff = layoutDiff + ++ (recyclerViewManager.firstItemOffset - ++ firstVisibleItemFirstItemOffset.current); + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), + }; ++ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; + if (diff !== 0 && + !pauseOffsetCorrection.current && + !recyclerViewManager.animationOptimizationsEnabled) { +@@ -147,13 +168,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + } + else if (useAndroidInvertedFallback) { + if (!shouldSkipAndroidInvertedCorrection) { ++ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the ++ // tracker's relative offset only resyncs on scroll events), so the header delta is ++ // already embedded in it — add only the layout diff on scrollTo paths. + const scrollToParams = horizontal + ? { +- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + } + : { +- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + }; + if (pendingAndroidInvertedRafId.current !== null) { +@@ -169,17 +193,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + else { + const scrollToParams = horizontal + ? { +- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + } + : { +- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + }; + (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); + } + if (hasDataChanged) { +- updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + diff, () => { }); ++ updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, () => { }); + recyclerViewManager.ignoreScrollEvents = true; + setTimeout(() => { + recyclerViewManager.ignoreScrollEvents = false; +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js +index 4d7a945..26133d6 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js +@@ -21,7 +21,7 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; + * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer + * - CompatScrollView: The animated scroll component + */ +-export function useSecondaryProps(props) { ++export function useSecondaryProps(props, onHeaderLayout) { + const { ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListEmptyComponent, ListEmptyComponentStyle, renderScrollComponent, refreshing, progressViewOffset, onRefresh, data, refreshControl: customRefreshControl, stickyHeaderConfig, inverted, horizontal, } = props; + const invertedTransformStyle = inverted + ? getInvertedTransformStyle(horizontal) +@@ -45,8 +45,8 @@ export function useSecondaryProps(props) { + if (!ListHeaderComponent) { + return null; + } +- return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle] }, getValidComponent(ListHeaderComponent))); +- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); ++ return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle], onLayout: onHeaderLayout }, getValidComponent(ListHeaderComponent))); ++ }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle, onHeaderLayout]); + /** + * Creates the footer component with optional styling. + */ +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index a7829d5..ec5d7b7 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -16,6 +16,7 @@ import React, { + import { + Animated, + I18nManager, ++ LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + Platform, +@@ -501,6 +502,31 @@ const RecyclerViewComponent = ( + [recyclerViewContext, recyclerViewManager] + ); + ++ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component ++ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep ++ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass ++ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, ++ // so a header resize cannot shift item positions. ++ const lastHeaderSizeRef = useRef(-1); ++ const onHeaderLayout = useCallback( ++ (event: LayoutChangeEvent) => { ++ if (inverted) { ++ return; ++ } ++ const headerSize = horizontal ++ ? event.nativeEvent.layout.width ++ : event.nativeEvent.layout.height; ++ if ( ++ lastHeaderSizeRef.current >= 0 && ++ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize) ++ ) { ++ recyclerViewContext.layout(); ++ } ++ lastHeaderSizeRef.current = headerSize; ++ }, ++ [horizontal, inverted, recyclerViewContext] ++ ); ++ + // Get secondary props and components + const { + refreshControl, +@@ -509,7 +535,7 @@ const RecyclerViewComponent = ( + renderEmpty, + CompatScrollView, + renderStickyHeaderBackdrop, +- } = useSecondaryProps(props); ++ } = useSecondaryProps(props, onHeaderLayout); + + if ( + !recyclerViewManager.getIsFirstLayoutComplete() && +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +index 3012391..7375752 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +@@ -57,6 +57,10 @@ export function useRecyclerViewController( + // Track the first visible item for maintaining scroll position + const firstVisibleItemKey = useRef(undefined); + const firstVisibleItemLayout = useRef(undefined); ++ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are ++ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta ++ // must be captured separately or offset correction is blind to it. ++ const firstVisibleItemFirstItemOffset = useRef(0); + + // Queue to store callbacks that should be executed after scroll offset updates + const pendingScrollCallbacks = useRef<(() => void)[]>([]); +@@ -96,6 +100,18 @@ export function useRecyclerViewController( + recyclerViewManager.getDataLength() > 0 && + recyclerViewManager.shouldMaintainVisibleContentPosition() + ) { ++ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the ++ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can ++ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking ++ // the viewport away from what the user is looking at. The header's own top never moves, so the correct ++ // correction while it is the anchor is none — drop the tracked item instead. ++ if ( ++ recyclerViewManager.getAbsoluteLastScrollOffset() < ++ recyclerViewManager.firstItemOffset ++ ) { ++ firstVisibleItemKey.current = undefined; ++ return; ++ } + // Update the tracked first visible item + const firstVisibleIndex = Math.max( + 0, +@@ -107,6 +123,8 @@ export function useRecyclerViewController( + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(firstVisibleIndex), + }; ++ firstVisibleItemFirstItemOffset.current = ++ recyclerViewManager.firstItemOffset; + } + } + }, [recyclerViewManager]); +@@ -156,15 +174,23 @@ export function useRecyclerViewController( + currentIndexOfFirstVisibleItem !== undefined && + currentIndexOfFirstVisibleItem >= 0 + ) { +- // Calculate the difference in position and apply the offset +- const diff = horizontal ++ // Calculate the difference in position and apply the offset. Item layouts are header-relative, ++ // so a ListHeaderComponent resize shifts every item on screen by the same amount without ++ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. ++ const layoutDiff = horizontal + ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - + firstVisibleItemLayout.current!.x + : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - + firstVisibleItemLayout.current!.y; ++ const diff = ++ layoutDiff + ++ (recyclerViewManager.firstItemOffset - ++ firstVisibleItemFirstItemOffset.current); + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), + }; ++ firstVisibleItemFirstItemOffset.current = ++ recyclerViewManager.firstItemOffset; + if ( + diff !== 0 && + !pauseOffsetCorrection.current && +@@ -175,20 +201,27 @@ export function useRecyclerViewController( + // console.log("scrollBy", diff); + scrollAnchorRef.current?.scrollBy(diff); + } else { ++ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the ++ // tracker's relative offset only resyncs on scroll events), so the header delta is ++ // already embedded in it — add only the layout diff on scrollTo paths. + const scrollToParams = horizontal + ? { +- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ x: ++ recyclerViewManager.getAbsoluteLastScrollOffset() + ++ layoutDiff, + animated: false, + } + : { +- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ y: ++ recyclerViewManager.getAbsoluteLastScrollOffset() + ++ layoutDiff, + animated: false, + }; + scrollViewRef.current?.scrollTo(scrollToParams); + } + if (hasDataChanged) { + updateScrollOffsetWithCallback( +- recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + () => {} + ); + recyclerViewManager.ignoreScrollEvents = true; +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx +index a64742c..7be3eb2 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx +@@ -1,4 +1,4 @@ +-import { Animated, RefreshControl } from "react-native"; ++import { Animated, LayoutChangeEvent, RefreshControl } from "react-native"; + import React, { useMemo } from "react"; + + import { RecyclerViewProps } from "../RecyclerViewProps"; +@@ -24,7 +24,10 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; + * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer + * - CompatScrollView: The animated scroll component + */ +-export function useSecondaryProps(props: RecyclerViewProps) { ++export function useSecondaryProps( ++ props: RecyclerViewProps, ++ onHeaderLayout?: (event: LayoutChangeEvent) => void ++) { + const { + ListHeaderComponent, + ListHeaderComponentStyle, +@@ -73,11 +76,19 @@ export function useSecondaryProps(props: RecyclerViewProps) { + return null; + } + return ( +- ++ + {getValidComponent(ListHeaderComponent)} + + ); +- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); ++ }, [ ++ ListHeaderComponent, ++ ListHeaderComponentStyle, ++ invertedTransformStyle, ++ onHeaderLayout, ++ ]); + + /** + * Creates the footer component with optional styling. diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 0ae0ea33142f..572a8d700d66 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -155,3 +155,23 @@ - Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2318 (for point 4) - E/App issue: https://github.com/Expensify/App/issues/92152 - PR introducing patch: https://github.com/Expensify/App/pull/93403 + +### [@shopify+flash-list+2.3.0+014+external-window-size.patch](@shopify+flash-list+2.3.0+014+external-window-size.patch) + +- Reason: Adds an **`overrideWindowSize`** prop that lets a list declare its visible window (`{width, height}`) instead of deriving it from `measureParentSize(internalViewRef)`. Needed for an *externally-driven* list — one whose `renderScrollComponent` is a non-scrolling `View` that grows to the full content height and receives synthetic scroll events from a parent scroller. Without this, FlashList measures the outer container (as tall as all content) as its viewport and renders every row, defeating virtualization. The change is minimal: `measureParentSize` is still assigned to `outerViewSize` and used for `containerViewSizeRef` (layout-change detection) and the 0×0 hidden-guard from patch 002; only the `windowSize` fed to `updateLayoutParams` is `overrideWindowSize ?? outerViewSize`. Fully backward compatible — when the prop is unset, `windowSize === outerViewSize` and behavior is byte-identical. Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table (`ExternalScrollFlashListTable`), which windows its rows against the unified list's vertical scroll. +- Files changed: `src/FlashListProps.ts`, `src/recyclerview/RecyclerView.tsx`, `dist/FlashListProps.d.ts`, `dist/recyclerview/RecyclerView.js`. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/91425 +- PR introducing patch: https://github.com/Expensify/App/pull/91422 + +### [@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch) + +- Reason: Makes `maintainVisibleContentPosition` aware of the `ListHeaderComponent`. Item layouts are header-relative, so MVCP was blind to the header in two symmetric ways: + 1. **Header resize was never corrected**: when the header changes height after layout (e.g. a nested virtualized table settling from estimated to measured row heights, ~400px on a 207-row table), every data item shifts on screen but no tracked `x`/`y` changes — the anchored item (e.g. a deep-linked report action positioned via `initialScrollIndex`) drifts out of the viewport with no correction. Fixed by capturing `firstItemOffset` alongside the anchor layout (`firstVisibleItemFirstItemOffset`) and including its delta in the correction diff. On the `ScrollAnchor.scrollBy` path (iOS/Android) the full diff is applied; on the `scrollTo` fallback paths (web, Android inverted) only the layout diff is added, because `getAbsoluteLastScrollOffset()` already reflects the current `firstItemOffset` (the tracker's relative offset only resyncs on scroll events), so the header delta is already embedded in it. + 2. **Wrong anchor while viewing the header**: `computeFirstVisibleIndexForOffsetCorrection` clamps the anchor to `Math.max(0, startIndex)`, so with the viewport over the header it tracked item 0 — possibly far below the fold — and "maintained" that off-screen item's position through data prepends/header growth, yanking the viewport away from the header (report opening at the top visibly jumped down to the chat). Fixed by dropping the anchor (`firstVisibleItemKey = undefined`) whenever the absolute scroll offset is smaller than `firstItemOffset`: the header is the user's visual anchor then, and its top never moves, so the correct correction is none. + Additionally, `RecyclerView` now observes the header wrapper's `onLayout` (main-axis size only, skipped for inverted lists where `firstItemOffset` is forced to 0) and triggers `recyclerViewContext.layout()` on change. Without this, a header that resizes from a commit inside its own subtree (the nested table settling) never causes the parent list to re-measure `firstItemOffset`, so items keep stale positions and `applyOffsetCorrection` never sees the delta. + Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table, where the whole table is the unified list's `ListHeaderComponent`: deep links into the report actions below the table now stay anchored while the table settles, and MVCP could be re-enabled for that mode (the `{disabled: true}` workaround is removed). +- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/hooks/useRecyclerViewController.tsx`, `src/recyclerview/hooks/useSecondaryProps.tsx`, and their `dist` counterparts. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/91425 +- PR introducing patch: https://github.com/Expensify/App/pull/91422 diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 17f6568687ea..c844178dd5f8 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1811,6 +1811,7 @@ const CONST = { THREAD_DISABLED: ['CREATED'], LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD: 2000, ACTION_VISIBLE_THRESHOLD: 250, + AUTOSCROLL_TO_TOP_THRESHOLD: 250, LINKED_MESSAGE_OFFSET: 40, MAX_GROUPING_TIME: 300000, }, diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx deleted file mode 100644 index 424b99923f59..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import FlatList from '@components/FlatList/FlatList'; -import useFlatListScrollKey from '@components/FlatList/hooks/useFlatListScrollKey'; - -import React, {useEffect, useRef} from 'react'; - -import type {BaseFlatListWithScrollKeyProps} from './types'; - -/** - * FlatList component that handles initial scroll key. - */ -function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKeyProps) { - const { - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor, - onViewableItemsChanged, - onContentSizeChange, - onScrollBeginDrag, - onWheel, - onTouchStartCapture, - ...restProps - } = 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; - }} - /> - ); -} - -export default BaseFlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx deleted file mode 100644 index cf49fa79a1cb..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import mergeRefs from '@libs/mergeRefs'; - -import type {LayoutChangeEvent, FlatList as RNFlatList} from 'react-native'; - -import React, {useCallback, useRef} from 'react'; - -import type {FlatListWithScrollKeyProps} from './types'; - -import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey({ref, ...props}: FlatListWithScrollKeyProps) { - const {initialScrollKey, onLayout, onContentSizeChange, ...rest} = props; - - const flatListHeight = useRef(0); - const shouldScrollToEndRef = useRef(false); - const listRef = useRef(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 ( - - ); -} - -export default FlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx deleted file mode 100644 index 3dbdda95fe57..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import type {FlatListWithScrollKeyProps} from './types'; - -import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey({ref, ...props}: FlatListWithScrollKeyProps) { - return ( - - ); -} - -export default FlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/types.ts b/src/components/FlatList/FlatListWithScrollKey/types.ts deleted file mode 100644 index 5a8145be74c3..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type {FlatListProps, ListRenderItem, FlatList as RNFlatList} 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; - ref: ForwardedRef; -}; - -type FlatListWithScrollKeyProps = Omit, 'onContentSizeChange'> & Pick, 'onContentSizeChange'>; - -export type {FlatListWithScrollKeyProps, BaseFlatListWithScrollKeyProps}; diff --git a/src/components/FlatList/getInitialPaginationSize/index.native.ts b/src/components/FlatList/getInitialPaginationSize/index.native.ts deleted file mode 100644 index 195448f7e450..000000000000 --- a/src/components/FlatList/getInitialPaginationSize/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.MOBILE_PAGINATION_SIZE; diff --git a/src/components/FlatList/getInitialPaginationSize/index.ts b/src/components/FlatList/getInitialPaginationSize/index.ts deleted file mode 100644 index 87ec6856aa20..000000000000 --- a/src/components/FlatList/getInitialPaginationSize/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.WEB_PAGINATION_SIZE; diff --git a/src/components/FlatList/hooks/useFlatListScrollKey.ts b/src/components/FlatList/hooks/useFlatListScrollKey.ts deleted file mode 100644 index 65cbd782b509..000000000000 --- a/src/components/FlatList/hooks/useFlatListScrollKey.ts +++ /dev/null @@ -1,183 +0,0 @@ -import getInitialPaginationSize from '@components/FlatList/getInitialPaginationSize'; -import RenderTaskQueue from '@components/FlatList/RenderTaskQueue'; -import type {FlatListInnerRefType} from '@components/FlatList/types'; -import type {ScrollViewProps} from '@components/ScrollView'; - -import usePrevious from '@hooks/usePrevious'; - -import getPlatform from '@libs/getPlatform'; - -import CONST from '@src/CONST'; - -import type {ForwardedRef, RefObject} from 'react'; -import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; - -import {createElement, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; - -import useFlatListHandle from './useFlatListHandle'; - -type FlatListScrollKeyProps = { - ref?: ForwardedRef>; - data: unknown[]; - keyExtractor: (item: unknown, index: number) => string; - initialScrollKey: string | null | undefined; - inverted: boolean; - onStartReached?: ((info: {distanceFromStart: number}) => void) | null; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; - remainingItemsToDisplay?: number; - onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; -}; - -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - -/** - * Non-generic implementation so OXC's React Compiler can memoize the hook. - * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). - */ -function useFlatListScrollKeyImpl({ - data, - keyExtractor, - initialScrollKey, - onStartReached, - inverted, - shouldEnableAutoScrollToTopThreshold, - renderItem, - ref, - remainingItemsToDisplay, - onScrollToIndexFailed, -}: FlatListScrollKeyProps) { - const [currentDataId, setCurrentDataId] = useState(() => { - if (initialScrollKey) { - return initialScrollKey; - } - return null; - }); - const currentDataIndex = currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId); - const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); - const [isQueueRendering, setIsQueueRendering] = useState(false); - - const shouldDuplicateData = !inverted && data.length === 1 && isInitialData && getPlatform() === CONST.PLATFORM.WEB; - - const displayedData = (() => { - if (shouldDuplicateData) { - return [{...(data.at(0) as Record), reportActionID: '0'}, ...data]; - } - if (currentDataIndex <= 0) { - return data; - } - const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); - })(); - - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; - - 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, Math.max(0, currentDataIndex)) : null); - }); - - const handleStartReached = (info: {distanceFromStart: number}) => { - renderQueue.add(info); - }; - - useEffect(() => { - if (inverted || data.length > 0) { - return; - } - handleStartReached({distanceFromStart: 0}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); - const maintainVisibleContentPosition = (() => { - if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { - return undefined; - } - - const config: ScrollViewProps['maintainVisibleContentPosition'] = { - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, - }; - - if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { - config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; - } - - return config; - })(); - - const handleRenderItem = ({item, index, separators}: ListRenderItemInfo) => { - if (shouldDuplicateData && index === 1) { - return createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); - } - - return renderItem({item, index: index + dataIndexDifference, separators}); - }; - - useEffect(() => { - if (inverted || isInitialData || isQueueRendering) { - return; - } - - requestAnimationFrame(() => { - setShouldPreserveVisibleContentPosition(false); - }); - }, [inverted, isInitialData, isQueueRendering]); - - const listRef = useRef | null>(null); - useFlatListHandle({ - ref, - listRef, - remainingItemsToDisplay, - setCurrentDataId, - onScrollToIndexFailed, - }); - - return { - handleStartReached, - setCurrentDataId, - displayedData, - maintainVisibleContentPosition, - isInitialData, - handleRenderItem, - listRef, - }; -} - -type FlatListScrollKeyPropsGeneric = { - ref?: ForwardedRef>; - data: T[]; - keyExtractor: (item: T, index: number) => string; - initialScrollKey: string | null | undefined; - inverted: boolean; - onStartReached?: ((info: {distanceFromStart: number}) => void) | null; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; - remainingItemsToDisplay?: number; - onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; -}; - -export default function useFlatListScrollKey(props: FlatListScrollKeyPropsGeneric) { - return useFlatListScrollKeyImpl(props as FlatListScrollKeyProps) as ReturnType & { - displayedData: T[]; - handleRenderItem: ListRenderItem; - listRef: RefObject | null>; - }; -} - -export {AUTOSCROLL_TO_TOP_THRESHOLD}; diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx new file mode 100644 index 000000000000..a209e7fd0c03 --- /dev/null +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -0,0 +1,248 @@ +import ScrollView from '@components/ScrollView'; + +import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; + +// Deliberately not the @components/FlashList wrapper: it doesn't type `ref` (this list needs one for layout reads), +// and its only addition — composer scroll-event emission — would fire duplicates here, since this list's scroll events +// are synthesized from the parent list's scroll, which already emits them. +import {FlashList} from '@shopify/flash-list'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; +import {View} from 'react-native'; + +/** + * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll + * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches + * the driver, which turns it into a synthetic scroll event that updates only the nested FlashList's render stack. + */ +type ScrollOffsetStore = { + getOffset: () => number; + setOffset: (offset: number) => void; + subscribe: (listener: () => void) => () => void; +}; + +function createScrollOffsetStore(): ScrollOffsetStore { + let offset = 0; + const listeners = new Set<() => void>(); + return { + getOffset: () => offset, + setOffset: (next: number) => { + if (next === offset) { + return; + } + offset = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +// The subset of the ScrollView imperative surface FlashList actually drives — `getScrollableNode` is read internally +// by RecyclerView for bound detection, and `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` back +// FlashList's public ref. Naming it makes "the part of ScrollView the driver must honor" explicit rather than erased. +type MinimalScrollRef = { + scrollTo: () => void; + scrollToEnd: () => void; + flashScrollIndicators: () => void; + getScrollableNode: () => View | null; + getNativeScrollRef: () => View | null; +}; + +// `store` and `offsetTop` are injected at runtime by FlashList via `overrideProps`, never by FlashList's own typed +// call site — declaring them optional makes the driver structurally a ScrollView component, so no cast is needed to +// pass it as `renderScrollComponent`. FlashList's renderScrollComponent wrapper passes the ref as a prop, so the +// driver takes `ref` directly (React 19 style) rather than via forwardRef. +type ExternalScrollDriverProps = Omit & { + /** Source of the parent list's vertical scroll offset. */ + store?: ScrollOffsetStore; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop?: number; + + /** Imperative handle FlashList drives (scrollTo/scrollToEnd/getScrollableNode…). */ + ref?: React.Ref; +}; + +/** + * Replacement scroll container for the nested table FlashList. It does NOT scroll — it is a plain View that grows to + * the full content height so the parent page scrolls through it — and it synthesizes FlashList's vertical scroll + * events from the parent's offset. FlashList reads `getScrollableNode` internally and delegates its public + * `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` to this ref; the scroll no-ops mean offset + * corrections settle below the fold exactly like the parent-driven windowing. Must be a stable module-level component: + * FlashList memoizes its scroll component on identity. + */ +function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, ref}: ExternalScrollDriverProps) { + const nodeRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + scrollTo: () => {}, + scrollToEnd: () => {}, + flashScrollIndicators: () => {}, + getScrollableNode: () => nodeRef.current, + getNativeScrollRef: () => nodeRef.current, + }), + [], + ); + + useEffect(() => { + if (!store) { + return; + } + const emit = () => { + // Offset into the nested list's own coordinate space (FlashList subtracts its measured firstItemOffset — + // the header height — internally). Only `contentOffset.y` is read for a vertical list, so nothing else is + // populated; windowing comes from the `overrideWindowSize` prop, not this event. + const y = Math.max(0, store.getOffset() - offsetTop); + try { + // Synthesizing a native scroll event requires an assertion: NativeSyntheticEvent's target/currentTarget + // are RN HostInstances that can't be constructed in JS. FlashList's vertical handler reads only + // contentOffset.y. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent); + } catch { + // During back-navigation teardown FlashList can be driven after its layout manager is gone, which + // throws ("LayoutManager is not initialized"). This call only feeds a synthetic scroll frame to + // window the nested list, so a dropped frame here is harmless — swallow it rather than crash. + } + }; + // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the + // measured offset, or FlashList's scroll handler change — none of which happen per scroll frame. + emit(); + return store.subscribe(emit); + }, [store, offsetTop, onScroll]); + + return ( + + {children} + + ); +} + +type ExternalScrollFlashListTableHandle = { + /** Page-space position of a row — where it sits within the parent's scrollable content. Derived from the nested + * list's layout data, so it works for rows that aren't mounted. The table owns this math (offsetTop + its own + * header height + the row's layout) so the parent never learns the nested coordinate system. */ + getRowPageOffset: (index: number) => {top: number; height: number} | undefined; +}; + +type ExternalScrollFlashListTableProps = { + /** Rows to render. FlashList windows and recycles them against the parent's scroll offset. */ + items: T[]; + + /** Stable key per row. */ + keyExtractor: (item: T, index: number) => string; + + /** Recycling bucket per row (transaction vs. group header) so FlashList reuses like with like. */ + getItemType: (item: T) => string; + + /** Renders a single row. */ + renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; + + /** Column header rendered above the rows and scrolled horizontally with them. */ + renderHeader: () => React.ReactElement | null; + + /** Estimated row height used before a row has been measured. */ + estimatedRowHeight: number; + + /** Full table width (wider than the viewport). Drives the horizontal scroll range. */ + contentWidth: number; + + /** Shared offset store fed by the parent's onScroll. */ + store: ScrollOffsetStore; + + /** Visible height of the parent viewport. */ + viewportHeight: number; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop: number; + + /** Imperative handle exposing row positions in page space (see ExternalScrollFlashListTableHandle). */ + ref?: React.Ref; +}; + +/** + * A vertically-virtualized, horizontally-scrollable table built on FlashList instead of a hand-rolled virtualized list. + * + * The nested FlashList is fed a non-scrolling `ExternalScrollDriver` as its scroll container, so it grows to full + * content height (the parent page scrolls through it) while FlashList still recycles rows against the parent's scroll + * offset — via the patched `overrideWindowSize` prop, which lets FlashList treat the parent viewport as its window + * instead of measuring its own (full-height) container. A single native horizontal ScrollView wraps the whole list, so + * all rows share one smooth horizontal scroll and nothing outside it moves sideways. + * + * LAYOUT NOTE: FlashList's outer container defaults to `flex: 1` + `overflow: hidden` (a clipping viewport). We + * override it via `style` below to grow to content height and not clip, since the page — not the list — owns vertical + * scroll. + */ +function ExternalScrollFlashListTable({ + items, + keyExtractor, + getItemType, + renderItem, + renderHeader, + estimatedRowHeight, + contentWidth, + store, + viewportHeight, + offsetTop, + ref, +}: ExternalScrollFlashListTableProps) { + const lastIndex = items.length - 1; + const listRef = useRef>(null); + + useImperativeHandle( + ref, + () => ({ + getRowPageOffset: (index: number) => { + const layout = listRef.current?.getLayout(index); + if (!layout) { + return undefined; + } + return {top: offsetTop + (listRef.current?.getFirstItemOffset() ?? 0) + layout.y, height: layout.height}; + }, + }), + [offsetTop], + ); + + return ( + + + ref={listRef} + data={items} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={({item, index}: ListRenderItemInfo) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})} + ListHeaderComponent={renderHeader()} + drawDistance={estimatedRowHeight * 12} + renderScrollComponent={ExternalScrollDriver} + // Consumed by ExternalScrollDriver (FlashList spreads overrideProps onto the scroll component). + overrideProps={{store, offsetTop}} + // Treat the parent viewport as the list's window instead of measuring the (full-height) driver View. + overrideWindowSize={{width: contentWidth, height: viewportHeight}} + // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own + // clipping viewport must be neutralized. + style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}} + scrollEnabled={false} + /> + + ); +} + +export default ExternalScrollFlashListTable; +export {createScrollOffsetStore}; +export type {ExternalScrollFlashListTableHandle}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 1c9778815139..a4bb1d1ef2b6 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,4 +1,3 @@ -import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -18,7 +17,6 @@ import useReportTransactionsCollection from '@hooks/useReportTransactionsCollect import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; @@ -51,14 +49,11 @@ import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostF import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; -import variables from '@styles/variables'; - import {getOlderActions, openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -68,7 +63,7 @@ import {getStableReportSelector} from '@src/selectors/Report'; import {pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; -import type {LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; /* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; @@ -78,7 +73,6 @@ import {DeviceEventEmitter, View} from 'react-native'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; -import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; import SelectionToolbar from './SelectionToolbar'; @@ -104,6 +98,23 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const {translate, getLocalDateFromDatetime} = useLocalize(); const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const reportScrollManager = useReportScrollManager(); + // The unified list writes its last item index here (see lastItemIndexRef prop). We jump to the bottom via + // scrollToIndex rather than scrollToEnd: scrollToEnd targets an estimated content-end offset, which on a large + // list (hundreds of transactions + chat) leaves the bottom blank until it renders/corrects. scrollToIndex + // targets the last item directly and renders around it, so the landing is not blank. + const lastItemIndexRef = useRef(0); + const updateLastItemIndex = useCallback((index: number) => { + lastItemIndexRef.current = index; + }, []); + + const scrollToBottom = useCallback(() => { + if (lastItemIndexRef.current < 0) { + return; + } + + reportScrollManager.scrollToIndex(lastItemIndexRef.current, {animated: false, viewPosition: 1}); + }, [reportScrollManager]); + const lastMessageTime = useRef(null); const didLayout = useRef(false); const [isVisible, setIsVisible] = useState(Visibility.isVisible); @@ -211,9 +222,11 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const listRef = useActionListRef(); const scrollingVerticalBottomOffset = useRef(0); - const scrollingVerticalTopOffset = useRef(0); - const wrapperViewRef = useRef(null); const readActionSkipped = useRef(false); + const stickToBottomRef = useRef(false); + const stickToBottomTimeoutRef = useRef(null); + // Set when the user taps "Latest messages"; the report is marked as read only once the scroll actually reaches the bottom. + const pendingMarkAsReadRef = useRef(false); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; const userActiveSince = useRef(DateUtils.getDBTime()); @@ -465,8 +478,13 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; scrollOffsetRef.current = scrollingVerticalBottomOffset.current; - // We additionally track the top offset to be able to scroll to the new transaction when it's added - scrollingVerticalTopOffset.current = contentOffset.y; + // Mark the report as read only once the scroll has actually reached the bottom. The jump fired by + // "Latest messages" settles over several frames as deferred items hydrate, so we wait for the real end. + if (pendingMarkAsReadRef.current && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + pendingMarkAsReadRef.current = false; + readActionSkipped.current = false; + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + } }, }); @@ -478,7 +496,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) reportActionsLength: reportActions.length, hasNewestReportAction, setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToEnd, + scrollToEnd: scrollToBottom, resetKey: report?.reportID ?? reportIDFromRoute ?? '', }); @@ -529,12 +547,14 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) return; } - // We want to scroll to the end of the list where the newest message is - // however scrollToEnd will not work correctly with items of variable sizes without `getItemLayout` - so we need to delay the scroll until every item rendered + // We want to scroll to the end of the list where the newest message is. We route through the indexed + // scrollToBottom (scrollToIndex) rather than scrollToEnd because scrollToEnd targets an estimated + // content-end offset that leaves the bottom blank on large transaction+chat lists. We still delay so + // the just-sent item has landed in the data before we jump. const index = visibleReportActions.findIndex((item) => item.reportActionID === reportAction?.reportActionID); if (index !== -1) { setTimeout(() => { - reportScrollManager.scrollToEnd(); + scrollToBottom(); }, DELAY_FOR_SCROLLING_TO_END); } else { setEnableScrollToEnd(true); @@ -543,7 +563,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) }, }); }, - [reportScrollManager, setIsFloatingMessageCounterVisible, visibleReportActions], + [scrollToBottom, setIsFloatingMessageCounterVisible, visibleReportActions], ); useEffect(() => { @@ -569,21 +589,21 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const index = visibleReportActions.findIndex((item) => item.reportActionID === lastActionEventId); if (enableScrollToEnd && index !== -1) { setTimeout(() => { - reportScrollManager.scrollToEnd(); + scrollToBottom(); }, DELAY_FOR_SCROLLING_TO_END); setEnableScrollToEnd(false); } - }, [visibleReportActions, lastActionEventId, enableScrollToEnd, reportScrollManager]); + }, [visibleReportActions, lastActionEventId, enableScrollToEnd, scrollToBottom]); - const renderItem = useCallback( - ({item: reportAction, index}: ListRenderItemInfo) => { + const renderReportAction = useCallback( + (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => { const displayAsGroup = - !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && - hasNextActionMadeBySameActor(visibleReportActions, index, isOffline); + !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, indexWithinReportActions, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); const shouldDisableContextMenuForConciergeDraft = draftReportActionID === reportAction.reportActionID; return ( - + { + const scrollToLatestMessages = useCallback(() => { setIsFloatingMessageCounterVisible(false); + stickToBottomRef.current = true; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + } + // Safety net: stop pinning after deferred content has had time to settle, so a much later + // unrelated layout change doesn't yank the user back down. + stickToBottomTimeoutRef.current = setTimeout(() => { + stickToBottomRef.current = false; + }, 2000); + if (!hasNewestReportAction) { openReport({reportID, introSelected, betas, hasReportActions: true}); - reportScrollManager.scrollToEnd(); + scrollToBottom(); return; } - reportScrollManager.scrollToEnd(); - readActionSkipped.current = false; - readNewestAction(reportID, true); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, reportID, introSelected, betas]); - - const scrollToNewTransaction = useCallback( - (pageY: number) => { - wrapperViewRef.current?.measureInWindow((x, y, w, height) => { - // If the new transaction is already visible, we don't need to scroll to it - if (pageY > 0 && pageY < height) { - return; - } - reportScrollManager.scrollToOffset(scrollingVerticalTopOffset.current + pageY - variables.scrollToNewTransactionOffset); - }); - }, - [reportScrollManager], - ); + // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). + pendingMarkAsReadRef.current = true; + scrollToBottom(); + }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, scrollToBottom, reportID, introSelected, betas]); + + useEffect(() => { + return () => { + if (!stickToBottomTimeoutRef.current) { + return; + } + clearTimeout(stickToBottomTimeoutRef.current); + }; + }, []); + + const onListContentSizeChange = () => { + if (!stickToBottomRef.current) { + return; + } + scrollToBottom(); + }; + + const onListScrollBeginDrag = () => { + stickToBottomRef.current = false; + // The user scrolled away before reaching the bottom, so cancel the pending read. + pendingMarkAsReadRef.current = false; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + stickToBottomTimeoutRef.current = null; + } + }; /** * Runs when the FlatList finishes laying out @@ -658,28 +701,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) markOpenReportEnd(report, {warm: !shouldShowOpenReportLoadingSkeleton}); }, [report, shouldShowOpenReportLoadingSkeleton]); - // 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); - // windowHeight can be smaller than the header+footer during transient mount/transition states - // (e.g. Wide RHP overlay animating in), which would make numToRender negative and crash - // VirtualizedList with "Invalid cells around viewport". Clamping to 0 lets the `|| undefined` - // fallback below kick in so FlatList uses its default. - const numToRender = Math.max(0, Math.ceil(availableHeight / minimumReportActionHeight)); - if (linkedReportActionID) { - return getInitialNumToRender(numToRender); - } - return numToRender || undefined; - }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); - const isReportEmpty = isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState; const showEmptyState = isReportEmpty; @@ -694,10 +715,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) }); return ( - + {/* Exactly one of these two branches is active at a time: 1. showEmptyState — genuinely empty report - 2. !isReportEmpty — report has data, render the FlatList */} + 2. !isReportEmpty — report has data, render the FlashList */} {showEmptyState && ( )} - {!isReportEmpty && ( - 0} + isLoadingInitialReportActions={showReportActionsLoadingState} + visibleReportActions={visibleReportActions} + renderReportAction={renderReportAction} + linkedReportActionID={linkedReportActionID} + listRef={listRef} + onLastItemIndexChange={updateLastItemIndex} accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} - testID="money-request-report-actions-list" - style={styles.overscrollBehaviorContain} - data={visibleReportActions} - renderItem={renderItem} - extraData={draftReportActionID} + onListLayout={recordTimeToMeasureItemLayout} + onScroll={trackVerticalScrolling} + onScrollBeginDrag={onListScrollBeginDrag} + onContentSizeChange={onListContentSizeChange} onViewableItemsChanged={onViewableItemsChanged} - keyExtractor={keyExtractor} - onLayout={recordTimeToMeasureItemLayout} onEndReached={onEndReached} - onEndReachedThreshold={0.75} onStartReached={onStartReached} - onStartReachedThreshold={0.75} - ListHeaderComponent={ - <> - - {!!reportStable && ( - 0} - isLoadingInitialReportActions={showReportActionsLoadingState} - /> - )} - - } - keyboardShouldPersistTaps="handled" - onScroll={trackVerticalScrolling} - contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} - ref={listRef} - ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState - removeClippedSubviews={false} - initialScrollKey={linkedReportActionID} + contentContainerStyle={shouldUseNarrowLayout ? styles.pt4 : styles.pt3} + isLoadingInitialActions={!!showReportActionsLoadingState} + skeletonReasonAttributes={skeletonReasonAttributes} /> )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 9b1830cad118..7fcfaccace84 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -24,7 +24,6 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {CardList, Policy, PolicyCategories, PolicyTagLists, Report, TransactionViolations} from '@src/types/onyx'; -import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import React, {useEffect, useRef, useState} from 'react'; @@ -80,9 +79,6 @@ type MoneyRequestReportTransactionItemProps = { /** Columns to show */ columns: SearchColumnType[]; - /** Callback function that scrolls to this transaction in case it is newly added */ - scrollToNewTransaction?: (offset: number) => void; - /** Callback function that navigates to the transaction thread */ onArrowRightPress?: (transactionID: string) => void; @@ -103,12 +99,17 @@ type MoneyRequestReportTransactionItemProps = { transactionThreadReportID?: string; }; -type MoneyRequestReportTransactionItemBodyProps = MoneyRequestReportTransactionItemProps & { +// `shouldBeHighlighted` is omitted: the highlight animation is computed by the outer component (so its timeline +// survives the narrow↔wide swap) and reaches the body as `animatedHighlightStyle`. +type MoneyRequestReportTransactionItemBodyProps = Omit & { /** Inline-edit values from `useTransactionInlineEdit`. Undefined on narrow layouts where the hook is skipped. */ inlineEdit?: InlineEditValues; /** Highlight animation style, computed by the parent so its state survives the narrow↔wide swap on resize. */ animatedHighlightStyle: ReturnType; + + /** Whether to skip deferring the RBR content. */ + shouldSkipDeferRBR?: boolean; }; function MoneyRequestReportTransactionItemBody({ @@ -128,15 +129,14 @@ function MoneyRequestReportTransactionItemBody({ postedColumnSize, amountColumnSize, taxAmountColumnSize, - scrollToNewTransaction, onArrowRightPress, - shouldBeHighlighted, nonPersonalAndWorkspaceCards, isLastItem = false, shouldScrollHorizontally = false, transactionThreadReportID, inlineEdit, animatedHighlightStyle, + shouldSkipDeferRBR = false, }: MoneyRequestReportTransactionItemBodyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -155,18 +155,6 @@ function MoneyRequestReportTransactionItemBody({ const fallbackEditingOnMouseDownRef = useRef(false); const wasEditingOnMouseDownRef = inlineEdit?.wasEditingOnMouseDownRef ?? fallbackEditingOnMouseDownRef; - const viewRef = useRef(null); - - // This useEffect scrolls to this transaction when it is newly added to the report - useEffect(() => { - if (!shouldBeHighlighted || !scrollToNewTransaction) { - return; - } - viewRef?.current?.measure((x, y, width, height, pageX, pageY) => { - scrollToNewTransaction?.(pageY); - }); - }, [scrollToNewTransaction, shouldBeHighlighted]); - useEffect(() => { if (!wasRecentlyEditingCell) { return; @@ -226,7 +214,6 @@ function MoneyRequestReportTransactionItemBody({ handleLongPress(transaction.transactionID); }} disabled={isTransactionPendingDelete(transaction)} - ref={viewRef} wrapperStyle={[animatedHighlightStyle, styles.userSelectNone, shouldUseNarrowLayout && !isLastItem && StyleUtils.getSelectedBorderBottomStyle(isSelected)]} > {({hovered}) => ( @@ -237,7 +224,6 @@ function MoneyRequestReportTransactionItemBody({ policy={policy} policyCategories={policyCategories} policyTagLists={policyTagLists} - transactionThreadReportID={transactionThreadReportID} isSelected={isSelected} dateColumnSize={dateColumnSize} postedColumnSize={postedColumnSize} @@ -269,6 +255,8 @@ function MoneyRequestReportTransactionItemBody({ onEditCategory={inlineEdit?.onEditCategory} onEditAmount={inlineEdit?.onEditAmount} onEditTag={inlineEdit?.onEditTag} + shouldSkipDeferRBR={shouldSkipDeferRBR} + transactionThreadReportID={transactionThreadReportID} /> )} @@ -292,6 +280,7 @@ function MoneyRequestReportTransactionItemWithInlineEdit(props: Omit ); } @@ -321,6 +311,7 @@ function MoneyRequestReportTransactionItem(props: MoneyRequestReportTransactionI ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 5605fcf5495e..855d458b2b6d 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,10 +1,8 @@ import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; -import MenuItem from '@components/MenuItem'; -import Modal from '@components/Modal'; +import type FlatListRefType from '@components/FlashList/types'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScrollView from '@components/ScrollView'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {SearchCustomColumnIds, SortOrder} from '@components/Search/types'; @@ -31,7 +29,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {getReportLayoutGroupBy, getReportLayoutSelection, setReportLayout} from '@libs/actions/ReportLayout'; import {clearActiveTransactionIDs, getActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import {resolveTransactionCardFields} from '@libs/CardUtils'; @@ -56,6 +53,7 @@ import { import type {SortableColumnName} from '@libs/ReportUtils'; import {compareValues, getColumnsToShow, getTableMinWidth, hasFlexColumn, isTransactionAmountTooLong, isTransactionTaxAmountTooLong} from '@libs/SearchUIUtils'; import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {transactionHasRBR} from '@libs/TransactionPreviewUtils'; import {getTransactionPendingAction, getVisibleTransactionViolations, isTransactionPendingDelete, shouldShowExpenseBreakdown} from '@libs/TransactionUtils'; import shouldShowTransactionPostedYear from '@libs/TransactionUtils/shouldShowTransactionPostedYear'; @@ -76,25 +74,66 @@ import type {StableReport} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -// ScrollView type is needed for the horizontal scroll ref; the project ScrollView component is used for rendering. -// eslint-disable-next-line no-restricted-imports -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {personalDetailsLoginSelector} from '@selectors/PersonalDetails'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import isEmpty from 'lodash/isEmpty'; -import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +import type {MoneyRequestReportTransactionLongPressModalHandle} from './MoneyRequestReportTransactionLongPressModal'; + import MoneyRequestReportGroupHeader from './MoneyRequestReportGroupHeader'; import MoneyRequestReportTableHeader from './MoneyRequestReportTableHeader'; import MoneyRequestReportTotalSpend from './MoneyRequestReportTotalSpend'; import MoneyRequestReportTransactionItem from './MoneyRequestReportTransactionItem'; +import MoneyRequestReportTransactionLongPressModal from './MoneyRequestReportTransactionLongPressModal'; +import MoneyRequestReportUnifiedList from './MoneyRequestReportUnifiedList'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; const PENDING_EXPENSE_REASON_ATTRIBUTES = {context: 'MoneyRequestReportTransactionList.PendingExpensePlaceholder'} as const; +type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { + /** Whether the transaction should be highlighted, when it is added to the report */ + shouldBeHighlighted?: boolean; +}; + +type TransactionListItemData = {type: 'section-header'; groupKey: string; group: OnyxTypes.GroupedTransactions} | {type: 'transaction'; transaction: TransactionWithOptionalHighlight}; + +/** + * Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state. + * Wide on purpose: this is the single integration point between TransactionList's internal state and the parent + * FlatList that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the + * same locals across multiple call sites without earning an abstraction. + */ +type MoneyRequestReportTransactionListController = { + /** Chrome rendered above the transaction items: group-by dropdown + columns button (or empty state). Always page-pinned. */ + beforeListContent: React.ReactElement; + + /** The sortable column-header row. Rendered inside the table's horizontal scroller so it tracks the columns; null on narrow layouts and empty reports. */ + tableColumnHeader: React.ReactElement | null; + + /** Flat array of items to render between beforeListContent and afterListContent. */ + transactionListItems: TransactionListItemData[]; + + /** Render a single transaction-list item. */ + renderTransactionListItem: (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; + + /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ + afterListContent: React.ReactElement | null; + + /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollFlashListTable` with its own horizontal scroller. */ + shouldScrollHorizontally: boolean; + + /** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */ + tableMinWidth: number; + + /** True when this report has no transactions; the parent should still render report actions but skip the transactions section. */ + isEmptyTransactions: boolean; +}; + const EMPTY_VIOLATIONS: OnyxTypes.TransactionViolations = []; /** @@ -144,9 +183,6 @@ type MoneyRequestReportTransactionListProps = { /** Array of report actions for the report that these transactions belong to */ reportActions: OnyxTypes.ReportAction[]; - /** scrollToNewTransaction callback used for scrolling to new transaction when it is created */ - scrollToNewTransaction: (offset: number) => void; - /** Whether the report that these transactions belong to has any chat comments */ hasComments: boolean; @@ -155,11 +191,54 @@ type MoneyRequestReportTransactionListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; -}; -type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { - /** Whether the transaction should be highlighted, when it is added to the report */ - shouldBeHighlighted?: boolean; + /** Reversed list of report actions to render below the transactions section in the unified list. */ + visibleReportActions: OnyxTypes.ReportAction[]; + + /** Renders a single report action row in the unified list. */ + renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; + + /** Report action ID the unified list should initially scroll to, when deep-linked. */ + linkedReportActionID: string | undefined; + + /** Ref forwarded to the underlying FlashList. */ + listRef: FlatListRefType; + + /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ + onLastItemIndexChange?: (index: number) => void; + + /** Accessibility label for the unified list. */ + accessibilityLabel: string; + + /** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */ + onListLayout: () => void; + + /** FlashList onScroll callback. */ + onScroll: (event: NativeSyntheticEvent) => void; + + /** FlashList onScrollBeginDrag callback. */ + onScrollBeginDrag: () => void; + + /** FlashList onContentSizeChange callback. */ + onContentSizeChange: () => void; + + /** FlashList onViewableItemsChanged callback. */ + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + + /** FlashList onEndReached callback. */ + onEndReached: () => void; + + /** FlashList onStartReached callback. */ + onStartReached: () => void; + + /** FlashList contentContainerStyle. */ + contentContainerStyle: StyleProp; + + /** Whether the initial report actions are still loading. */ + isLoadingInitialActions: boolean; + + /** Reason attributes forwarded to the loading skeleton span. */ + skeletonReasonAttributes: SkeletonSpanReasonAttributes; }; type SortedTransactions = { @@ -174,25 +253,39 @@ function MoneyRequestReportTransactionList({ isReportVisible = true, reportActions, hasPendingDeletionTransaction = false, - scrollToNewTransaction, policy, hasComments, onLayout, isLoadingInitialReportActions = false, + visibleReportActions, + renderReportAction, + linkedReportActionID, + listRef, + onLastItemIndexChange, + accessibilityLabel, + onListLayout, + onScroll, + onScrollBeginDrag, + onContentSizeChange, + onViewableItemsChanged, + onEndReached, + onStartReached, + contentContainerStyle, + isLoadingInitialActions, + skeletonReasonAttributes, }: MoneyRequestReportTransactionListProps) { useCopySelectionHelper(); const {convertToDisplayString} = useCurrencyListActions(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'CheckSquare', 'ReceiptPlus', 'Columns', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'ReceiptPlus', 'Columns', 'Plus']); const {translate, localeCompare} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); const navigateToTransactionThread = useNavigateToTransactionThread(); - const [isModalVisible, setIsModalVisible] = useState(false); - const [selectedTransactionID, setSelectedTransactionID] = useState(''); + const longPressModalRef = useRef(null); const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); const {isOffline} = useNetwork(); @@ -432,20 +525,6 @@ function MoneyRequestReportTransactionList({ const {windowWidth, windowHeight} = useWindowDimensions(); const minTableWidth = getTableMinWidth(columnsToShow); const shouldScrollHorizontally = !shouldUseNarrowLayout && minTableWidth > windowWidth; - const horizontalScrollViewRef = useRef(null); - const horizontalScrollOffsetRef = useRef(0); - - const handleHorizontalScroll = useCallback((event: NativeSyntheticEvent) => { - horizontalScrollOffsetRef.current = event.nativeEvent.contentOffset.x; - }, []); - - // Restore horizontal scroll position synchronously before paint when transactions change - useLayoutEffect(() => { - if (!shouldScrollHorizontally || horizontalScrollOffsetRef.current <= 0) { - return; - } - horizontalScrollViewRef.current?.scrollTo({x: horizontalScrollOffsetRef.current, animated: false}); - }, [sortedTransactions, shouldScrollHorizontally]); // Latch the user's most recent selection so the popover label and grouping mode never flick through the // (layoutOption=null, groupByOption=null) → CATEGORY default while the two NVPs settle in separate render passes. @@ -596,10 +675,9 @@ function MoneyRequestReportTransactionList({ toggleTransaction(transactionID); return; } - setSelectedTransactionID(transactionID); - setIsModalVisible(true); + longPressModalRef.current?.show(transactionID); }, - [isSmallScreenWidth, isMobileSelectionModeEnabled, toggleTransaction, setSelectedTransactionID, setIsModalVisible], + [isSmallScreenWidth, isMobileSelectionModeEnabled, toggleTransaction], ); const handleOnPress = useCallback( @@ -697,6 +775,20 @@ function MoneyRequestReportTransactionList({ return visibleTransactions.at(-1)?.transactionID; }, [shouldGroupTransactions, groupedTransactions, resolvedTransactions, isOffline]); + const listItems: TransactionListItemData[] = []; + if (shouldGroupTransactions) { + for (const group of groupedTransactions) { + listItems.push({type: 'section-header', groupKey: group.groupKey, group}); + for (const transaction of group.transactions) { + listItems.push({type: 'transaction', transaction}); + } + } + } else { + for (const transaction of resolvedTransactions) { + listItems.push({type: 'transaction', transaction}); + } + } + const violationsByTransactionID = useMemo(() => { const map = new Map(); const email = currentUserDetails.email ?? ''; @@ -708,84 +800,70 @@ function MoneyRequestReportTransactionList({ return map; }, [resolvedTransactions, allTransactionViolations, currentUserDetails.email, currentUserDetails.accountID, report, ownerLogin, policy]); - const renderTransactionItem = (transaction: TransactionWithOptionalHighlight) => ( - - ); + const renderTransactionListItem = (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => { + const narrowSectionWrapperStyle = shouldUseNarrowLayout + ? [styles.highlightBG, position.isFirst && styles.tableTopRadius, position.isLast && styles.tableBottomRadius, (position.isFirst || position.isLast) && styles.overflowHidden] + : undefined; - const transactionItems = shouldGroupTransactions - ? groupedTransactions.map((group) => { - const selectionState = groupSelectionState.get(group.groupKey) ?? { - isSelected: false, - isIndeterminate: false, - isDisabled: false, - pendingAction: undefined, - }; - return ( - - - {group.transactions.map((transaction) => renderTransactionItem(transaction))} - - ); - }) - : resolvedTransactions.map((transaction) => renderTransactionItem(transaction)); - - const narrowListWrapper = shouldUseNarrowLayout ? [styles.highlightBG, styles.tableTopRadius, styles.tableBottomRadius, styles.overflowHidden] : undefined; - - const transactionListContent = ( - - {narrowListWrapper ? {transactionItems} : transactionItems} - {showPendingExpensePlaceholder && ( - - )} - - ); + if (item.type === 'section-header') { + const selectionState = groupSelectionState.get(item.groupKey) ?? { + isSelected: false, + isIndeterminate: false, + isDisabled: false, + pendingAction: undefined, + }; + return ( + + + + + + ); + } + const transaction = item.transaction; + return ( + + + + + + ); + }; const tableHeaderContent = ( @@ -850,28 +928,24 @@ function MoneyRequestReportTransactionList({ ); - if (isEmptyTransactions) { - return ( - <> - - - - ); - } - - return ( + const beforeListContent = isEmptyTransactions ? ( <> + + + + ) : ( + {shouldShowGroupedTransactions && ( )} - {!shouldUseNarrowLayout && !shouldScrollHorizontally && tableHeaderContent} - {shouldScrollHorizontally ? ( - - - {tableHeaderContent} - {transactionListContent} - - - ) : ( - transactionListContent + + ); + + // The column-header row is kept separate from beforeListContent so the horizontal-table layout can render it + // inside the table's horizontal scroller (it must track the columns) while the group-by/columns controls above + // stay pinned to the page. In the inline layout the two are rendered back-to-back, preserving the original order. + const tableColumnHeader = isEmptyTransactions || shouldUseNarrowLayout ? null : tableHeaderContent; + + const afterListContent = isEmptyTransactions ? null : ( + + {showPendingExpensePlaceholder && ( + + + )} - setIsModalVisible(false)} - shouldPreventScrollOnFocus - > - { - if (!isMobileSelectionModeEnabled) { - turnOnMobileSelectionMode(); - } - toggleTransaction(selectedTransactionID); - setIsModalVisible(false); - }} - /> - + + ); + + const controller: MoneyRequestReportTransactionListController = { + beforeListContent, + tableColumnHeader, + transactionListItems: isEmptyTransactions ? [] : listItems, + renderTransactionListItem, + afterListContent, + shouldScrollHorizontally, + tableMinWidth: minTableWidth, + isEmptyTransactions, + }; + + return ( + <> + + ); } export default memo(MoneyRequestReportTransactionList); -export type {TransactionWithOptionalHighlight}; +export type {TransactionWithOptionalHighlight, TransactionListItemData, MoneyRequestReportTransactionListController}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx new file mode 100644 index 000000000000..3f96a1fe6d57 --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx @@ -0,0 +1,61 @@ +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; + +import CONST from '@src/CONST'; + +import type {Ref} from 'react'; + +import React, {useImperativeHandle, useState} from 'react'; + +type MoneyRequestReportTransactionLongPressModalHandle = { + show: (transactionID: string) => void; +}; + +type MoneyRequestReportTransactionLongPressModalProps = { + isMobileSelectionModeEnabled: boolean; + toggleTransaction: (transactionID: string) => void; + ref: Ref; +}; + +function MoneyRequestReportTransactionLongPressModal({isMobileSelectionModeEnabled, toggleTransaction, ref}: MoneyRequestReportTransactionLongPressModalProps) { + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + const [isVisible, setIsVisible] = useState(false); + const [selectedTransactionID, setSelectedTransactionID] = useState(''); + + useImperativeHandle(ref, () => ({ + show: (transactionID: string) => { + setSelectedTransactionID(transactionID); + setIsVisible(true); + }, + })); + + return ( + setIsVisible(false)} + shouldPreventScrollOnFocus + > + { + if (!isMobileSelectionModeEnabled) { + turnOnMobileSelectionMode(); + } + toggleTransaction(selectedTransactionID); + setIsVisible(false); + }} + /> + + ); +} + +export default MoneyRequestReportTransactionLongPressModal; +export type {MoneyRequestReportTransactionLongPressModalHandle}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx new file mode 100644 index 000000000000..3560f69f7664 --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -0,0 +1,355 @@ +import FlashList from '@components/FlashList'; +import type FlatListRefType from '@components/FlashList/types'; + +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; + +import variables from '@styles/variables'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; + +import React, {memo, useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import type {ExternalScrollFlashListTableHandle} from './ExternalScrollFlashListTable'; +import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; + +import ExternalScrollFlashListTable, {createScrollOffsetStore} from './ExternalScrollFlashListTable'; +import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; +import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; + +/** Single virtualized data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ +type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction}; + +const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; + +function unifiedListKeyExtractor(item: UnifiedListItem) { + switch (item.type) { + case 'section-header': + return `group-${item.groupKey}`; + case 'transaction': + return item.transaction.transactionID; + case 'transactions-footer': + return 'transactions-footer'; + case 'report-action': + return item.action.reportActionID; + default: + return ''; + } +} + +function unifiedListItemType(item: UnifiedListItem) { + return item.type === 'report-action' ? item.action.actionName : item.type; +} + +type MoneyRequestReportFlashListProps = FlashListProps & { + /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ + ref: FlatListRefType; +}; + +/** + * Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based + * list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the + * ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is + * needed. The scroll manager relies on the FlashList registering into this slot. + */ +function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { + return ( + + // thin forwarder; spreading the props (including the ref) is the point + {...props} + /> + ); +} + +type MoneyRequestReportUnifiedListProps = { + controller: MoneyRequestReportTransactionListController; + report: OnyxTypes.Report; + policy?: OnyxTypes.Policy; + visibleReportActions: OnyxTypes.ReportAction[]; + renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; + linkedReportActionID: string | undefined; + newTransactionID?: string; + listRef: FlatListRefType; + accessibilityLabel: string; + onLayout: () => void; + onScroll: (event: NativeSyntheticEvent) => void; + onScrollBeginDrag: () => void; + onContentSizeChange: () => void; + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + onEndReached: () => void; + onStartReached: () => void; + contentContainerStyle: StyleProp; + isOffline: boolean; + isLoadingInitialActions: boolean; + skeletonReasonAttributes: SkeletonSpanReasonAttributes; + /** Reports the index of the last list item so callers can jump to the bottom via scrollToIndex (which renders the + * landing region, unlike scrollToEnd's estimated-offset jump that leaves the bottom blank on large lists). */ + onLastItemIndexChange?: (index: number) => void; +}; + +function MoneyRequestReportUnifiedList({ + controller, + report, + policy, + visibleReportActions, + renderReportAction, + linkedReportActionID, + newTransactionID, + listRef, + accessibilityLabel, + onLayout, + onScroll, + onScrollBeginDrag, + onContentSizeChange, + onViewableItemsChanged, + onEndReached, + onStartReached, + contentContainerStyle, + isOffline, + isLoadingInitialActions, + skeletonReasonAttributes, + onLastItemIndexChange, +}: MoneyRequestReportUnifiedListProps) { + // When the table is wider than the viewport it can't share the horizontally-scrolled container with the chat (chat + // would drift sideways / jump on web). Instead the FlashList keeps ONLY the report actions virtualized, and the table is + // rendered as the list header via ExternalScrollFlashListTable — a nested FlashList in its own single native + // horizontal scroller that windows its rows against THIS list's vertical scroll offset. Chat never lives inside a + // horizontal scroller, so it never moves sideways. Everywhere else the transactions stay virtualized inline with + // the report actions. + const isHorizontalTable = controller.shouldScrollHorizontally && !controller.isEmptyTransactions; + const shouldInlineTransactions = !isHorizontalTable && !controller.isEmptyTransactions; + + const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); + const data: UnifiedListItem[] = shouldInlineTransactions ? [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems] : reportActionItems; + + // Report actions load separately from transactions. When transactions are inlined, `data` is already non-empty + // (transaction rows + footer) while comments are still loading, so ListEmptyComponent can never surface the + // comments loading skeleton. Gate the skeleton on the report actions being empty and render it as the list footer + // (right below the transaction section, where comments will appear) in that case. + const shouldShowActionsLoadingSkeleton = !isOffline && isLoadingInitialActions && reportActionItems.length === 0; + + // Report the last index so callers can jump to the bottom via scrollToIndex. + const lastDataIndex = data.length - 1; + + useEffect(() => { + onLastItemIndexChange?.(lastDataIndex); + }, [lastDataIndex, onLastItemIndexChange]); + + const lastTransactionItemIndex = controller.transactionListItems.length - 1; + const reportActionIndexOffset = shouldInlineTransactions ? controller.transactionListItems.length + 1 : 0; + + // Latest viewable items, kept current from onViewableItemsChanged, so the new-transaction scroll can skip when the row is already on screen. + const viewableItemsRef = useRef([]); + + // Handle to the nested table (horizontal mode) — read for row page positions, never driven to scroll (its scroll is a no-op). + const tableRef = useRef(null); + + // Viewport height + table offset fed to the nested table so it can window its rows against this list's scroll. + // tableOffsetTop is the height of everything above the table region (the report-fields header). + // Seed with the window height (a safe over-estimate) so the nested list windows against a non-zero viewport on the + // first paint — otherwise a height of 0 renders the transactions area blank until onLayout corrects it next frame. + const {windowHeight} = useWindowDimensions(); + const [viewportHeight, setViewportHeight] = useState(windowHeight); + const [tableOffsetTop, setTableOffsetTop] = useState(0); + + // A subscribe/notify store carries the scroll offset to the nested table FlashList with zero parent re-renders. + // Lazy useState initializer (not useRef.current) so it is created exactly once without reading a ref during render. + const [scrollOffsetStore] = useState(createScrollOffsetStore); + + // Reset the offset to the top whenever the report changes. A report view opens at the top, but the store only + // updates from onScroll — so without this a stale offset + useEffect(() => { + scrollOffsetStore.setOffset(0); + }, [report.reportID, scrollOffsetStore]); + + const handleScroll = (event: NativeSyntheticEvent) => { + // Always feed the offset store (emitter, not state: the nested FlashList updates its own render stack without + // re-rendering the parent). Feed it even in inline mode — the store has no subscribers then, so this is a cheap + // write — so that if the layout flips to the horizontal table, the nested list windows against the real scroll + // offset instead of a stale 0. + scrollOffsetStore.setOffset(event.nativeEvent.contentOffset.y); + onScroll(event); + }; + + const handleLayout = (event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + onLayout(); + }; + + // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against + // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, + // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. + const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + // Keep the raw array so the new-transaction effect can tell whether the new row is already on screen. + viewableItemsRef.current = info.viewableItems; + if (reportActionIndexOffset === 0) { + onViewableItemsChanged(info); + return; + } + onViewableItemsChanged({ + ...info, + viewableItems: info.viewableItems.map((item) => ({...item, index: item.index !== null ? item.index - reportActionIndexOffset : null})), + }); + }; + + const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { + switch (item.type) { + case 'section-header': + case 'transaction': + return controller.renderTransactionListItem(item, {isFirst: index === 0, isLast: index === lastTransactionItemIndex}); + case 'transactions-footer': + return controller.afterListContent; + case 'report-action': + return renderReportAction(item.action, index - reportActionIndexOffset); + default: + return null; + } + }; + + const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; + const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; + + // FlashList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is + // often not in `visibleReportActions` yet (it paginates in after mount), so the mount-only hint resolves to + // undefined and the list never anchors on the linked message. Re-anchor imperatively once the linked action is + // present. Guarded so it fires exactly once per linked target and never yanks the user after they've scrolled. + const hasAnchoredLinkedActionRef = useRef(false); + useEffect(() => { + hasAnchoredLinkedActionRef.current = false; + }, [linkedReportActionID]); + + useEffect(() => { + if (!linkedReportActionID || initialScrollIndex === undefined || hasAnchoredLinkedActionRef.current) { + return; + } + hasAnchoredLinkedActionRef.current = true; + // Defer to the next frame so the newly paginated-in rows are laid out before we scroll to the target. + const rafId = requestAnimationFrame(() => { + listRef?.current?.scrollToIndex({index: initialScrollIndex, animated: false}); + }); + return () => cancelAnimationFrame(rafId); + }, [linkedReportActionID, initialScrollIndex, listRef]); + + // Scroll a newly-created transaction into view, once per transaction. The rows are virtualized, so the row can't + // drive this itself (an off-window row never mounts) — the list scrolls to it by index/layout instead, which + // reaches unmounted rows. Skipped when the row is already on screen so the user isn't yanked. + const scrolledToNewTransactionIDRef = useRef(undefined); + + // The index is derived at render (not inside the effect) so the effect keys on a stable number — the items array + // identity churns across the re-renders that follow a transaction insert, and re-firing the effect would cancel + // the scheduled frame below before it runs. + const newTransactionTableIndex = newTransactionID + ? controller.transactionListItems.findIndex((item) => item.type === 'transaction' && item.transaction.transactionID === newTransactionID) + : -1; + + useEffect(() => { + if (newTransactionTableIndex < 0 || scrolledToNewTransactionIDRef.current === newTransactionID) { + return; + } + + if (shouldInlineTransactions) { + // Inline: the transaction is a main-list item at the same index (transactions lead `data`). + const rafId = requestAnimationFrame(() => { + // The ID is consumed inside the frame (not at schedule time): if a re-fire cancels this frame, the next + // effect run reschedules instead of treating the scroll as done. + scrolledToNewTransactionIDRef.current = newTransactionID; + if (viewableItemsRef.current.some((token) => token.index === newTransactionTableIndex)) { + return; + } + listRef?.current?.scrollToIndex({index: newTransactionTableIndex, animated: true, viewPosition: 0.5}); + }); + return () => cancelAnimationFrame(rafId); + } + + // Horizontal table: the rows live in a nested FlashList whose own scroll is a no-op — only the parent page + // scrolls. Ask the table where the row sits in page space (works for unmounted rows) and scroll the parent there. + const rafId = requestAnimationFrame(() => { + scrolledToNewTransactionIDRef.current = newTransactionID; + const row = tableRef.current?.getRowPageOffset(newTransactionTableIndex); + if (!row) { + return; + } + const scrollOffset = scrollOffsetStore.getOffset(); + if (row.top >= scrollOffset && row.top + row.height <= scrollOffset + viewportHeight) { + return; + } + listRef?.current?.scrollToOffset({offset: Math.max(0, row.top - viewportHeight / 2), animated: true}); + }); + return () => cancelAnimationFrame(rafId); + }, [newTransactionID, newTransactionTableIndex, shouldInlineTransactions, viewportHeight, scrollOffsetStore, listRef]); + + const reportFieldsHeader = ( + + ); + + return ( + + {/* Report fields + group-by/columns controls stay pinned to the page; only the column header and + rows scroll horizontally. Measured together so the table's offset into the page is exact. */} + setTableOffsetTop(event.nativeEvent.layout.height)}> + {reportFieldsHeader} + {controller.beforeListContent} + + + items={controller.transactionListItems} + keyExtractor={unifiedListKeyExtractor} + getItemType={unifiedListItemType} + renderItem={(item, _index, meta) => controller.renderTransactionListItem(item, meta)} + renderHeader={() => controller.tableColumnHeader} + estimatedRowHeight={variables.tableRowHeight} + contentWidth={controller.tableMinWidth} + store={scrollOffsetStore} + viewportHeight={viewportHeight} + offsetTop={tableOffsetTop} + ref={tableRef} + /> + {/* Rendered outside the horizontal scroller so the totals/add-expense summary stays pinned to the viewport. */} + {controller.afterListContent} + + ) : ( + <> + {reportFieldsHeader} + {controller.beforeListContent} + {controller.tableColumnHeader} + + ) + } + keyboardShouldPersistTaps="handled" + onScroll={handleScroll} + onScrollBeginDrag={onScrollBeginDrag} + onContentSizeChange={onContentSizeChange} + contentContainerStyle={contentContainerStyle} + ListEmptyComponent={shouldShowActionsLoadingSkeleton ? : undefined} + ListFooterComponent={shouldInlineTransactions && shouldShowActionsLoadingSkeleton ? : undefined} + drawDistance={1000} + /> + ); +} + +export default memo(MoneyRequestReportUnifiedList); diff --git a/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx b/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx index 1bb1cfb01582..bb9307b59aef 100644 --- a/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx @@ -2,10 +2,14 @@ import React, {useDeferredValue} from 'react'; import TransactionItemRowRBR from './TransactionItemRowRBR'; -type DeferredTransactionItemRowRBRProps = React.ComponentProps; +type DeferredTransactionItemRowRBRProps = React.ComponentProps & { + /** When false, renders RBR immediately. Use false in FlashList rows to avoid recycling/layout issues from deferred mount. */ + shouldDefer?: boolean; +}; -function DeferredTransactionItemRowRBR(props: DeferredTransactionItemRowRBRProps) { - const shouldRender = useDeferredValue(true, false); +function DeferredTransactionItemRowRBR({shouldDefer = true, ...props}: DeferredTransactionItemRowRBRProps) { + const deferredShouldRender = useDeferredValue(true, false); + const shouldRender = shouldDefer ? deferredShouldRender : true; // Skip placeholder while deferring to avoid layout shift on rows without RBR content if (!shouldRender) { diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index 26acd6803745..59c84da91931 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -15,7 +15,7 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import type {TransactionItemRowNarrowComputedData, TransactionItemRowProps} from './types'; +import type {TransactionItemRowNarrowComputedData, TransactionItemRowProps, TransactionItemRowRBRDeferControlProps} from './types'; import DeferredChatBubbleCell from './DataCells/DeferredChatBubbleCell'; import MerchantOrDescriptionCell from './DataCells/MerchantCell'; @@ -48,7 +48,8 @@ type TransactionItemRowNarrowProps = Pick< | 'shouldShowArrowRightOnNarrowLayout' | 'checkboxSentryLabel' > & - TransactionItemRowNarrowComputedData; + TransactionItemRowNarrowComputedData & + TransactionItemRowRBRDeferControlProps; function TransactionItemRowNarrow({ transactionItem, @@ -72,6 +73,7 @@ function TransactionItemRowNarrow({ onArrowRightPress, shouldShowArrowRightOnNarrowLayout, checkboxSentryLabel, + shouldDeferRBR = true, bgActiveStyles, merchant, merchantOrDescription, @@ -179,6 +181,7 @@ function TransactionItemRowNarrow({ {shouldShowErrors && ( & - TransactionItemRowWideComputedData; +type TransactionItemRowWideProps = Omit< + TransactionItemRowProps, + 'shouldUseNarrowLayout' | 'isAttendeesEnabledForMovingPolicy' | 'isLargeScreenWidth' | 'shouldShowCheckbox' | 'shouldSkipDeferRBR' +> & + TransactionItemRowWideComputedData & + TransactionItemRowRBRDeferControlProps; function TransactionItemRowWide({ transactionItem, @@ -91,6 +95,7 @@ function TransactionItemRowWide({ shouldStopRadioButtonMouseDownPropagation = false, radioButtonContainerStyle, shouldShowErrors = true, + shouldDeferRBR = true, isDisabled = false, shouldDisableActionPointerEvents = false, violations, @@ -709,6 +714,7 @@ function TransactionItemRowWide({ {shouldShowErrors && ( ); } @@ -256,6 +259,7 @@ function TransactionItemRow({ totalPerAttendee={!attendeesCount || totalAmount === undefined ? undefined : totalAmount / attendeesCount} createdAt={createdAt} transactionThreadReportID={transactionThreadReportID} + shouldDeferRBR={shouldDeferRBR} isMarkAsDone={shouldUseMarkAsDoneCopy} /> ); diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 80f9a8757161..d52252b206ef 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -118,6 +118,14 @@ type TransactionItemRowProps = { canEditCategory?: boolean; canEditAmount?: boolean; canEditTag?: boolean; + + /** When true, RBR content renders immediately instead of via useDeferredValue. Use in FlashList contexts. */ + shouldSkipDeferRBR?: boolean; +}; + +/** Derived from shouldSkipDeferRBR; passed to layout variants for DeferredTransactionItemRowRBR. */ +type TransactionItemRowRBRDeferControlProps = { + shouldDeferRBR?: boolean; }; /** Window position of the hovered cell used to anchor the receipt preview beside the row. */ @@ -151,4 +159,11 @@ type TransactionItemRowWideComputedData = Omit= AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { return; } diff --git a/src/hooks/useScrollToEndOnNewMessageReceived.ts b/src/hooks/useScrollToEndOnNewMessageReceived.ts index 59922a82a37d..6ce82241fe73 100644 --- a/src/hooks/useScrollToEndOnNewMessageReceived.ts +++ b/src/hooks/useScrollToEndOnNewMessageReceived.ts @@ -1,4 +1,4 @@ -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; +import CONST from '@src/CONST'; import type React from 'react'; @@ -66,7 +66,7 @@ function useScrollToEndOnNewMessageReceived({ const didListSizeChange = sizeChangeType === 'grewFromReportActions' ? reportActionSize.current > (reportActionsLength ?? 0) : reportActionSize.current !== visibleActionsLength; if ( - scrollOffsetRef.current < AUTOSCROLL_TO_TOP_THRESHOLD && + scrollOffsetRef.current < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionID && didListSizeChange && hasNewestReportAction && diff --git a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts b/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts deleted file mode 100644 index 4d0986216e59..000000000000 --- a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts +++ /dev/null @@ -1,4 +0,0 @@ -function getInitialNumToRender(numToRender: number): number { - return numToRender; -} -export default getInitialNumToRender; diff --git a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts b/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts deleted file mode 100644 index 217d5d3789dd..000000000000 --- a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -const DEFAULT_NUM_TO_RENDER = 50; - -function getInitialNumToRender(numToRender: number): number { - // For web environment, it's crucial to set this value equal to or higher than the maxToRenderPerBatch setting. If it's set lower, the 'onStartReached' event will be triggered excessively, every time an additional item enters the virtualized list. - return Math.max(numToRender, DEFAULT_NUM_TO_RENDER); -} -export default getInitialNumToRender; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 1267585eacfb..7b99d5d56264 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -397,8 +397,6 @@ export default { searchListContentWithFiltersMarginTop: 162, searchTopBarZIndex: 9, - scrollToNewTransactionOffset: 300, - searchAutocompleteInputSkeletonHeight: 8, searchAutocompleteInputSkeletonWidth: 145,