From e13e3564eac321e64fd8f3aa134c36c19c837e49 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 10:00:28 +0100 Subject: [PATCH 01/39] Update FlashList lib to v2.3.0 so it has an inverted flag. Add a patch to improve scroll on web. --- package-lock.json | 8 +- package.json | 2 +- ...fix-horizontal-height-normalization.patch} | 2 +- ...fix-inverted-scroll-direction-on-web.patch | 154 ++++++++++++++++++ patches/@shopify/flash-list/details.md | 9 +- 5 files changed, 168 insertions(+), 7 deletions(-) rename patches/@shopify/flash-list/{@shopify+flash-list+2.2.0.patch => @shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch} (98%) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch diff --git a/package-lock.json b/package-lock.json index c239c41419d1..a44fcda23f70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", "@sentry/react-native": "8.2.0", - "@shopify/flash-list": "2.2.0", + "@shopify/flash-list": "^2.3.0", "@shopify/react-native-skia": "^2.4.14", "@ua/react-native-airship": "~25.0.0", "array.prototype.tosorted": "^1.1.4", @@ -13962,9 +13962,9 @@ "license": "MIT" }, "node_modules/@shopify/flash-list": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.2.0.tgz", - "integrity": "sha512-mL61IofcfBNRZ/qazIf+pghGULkcZUQ7EZNldH1JBbIjtDb25ADSiQrt62ZTnRz0H5+bPFEZUmN9+WChHzX8pw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.0.tgz", + "integrity": "sha512-DR7VuN8KJHTYj9zv1/IhpqrMBMQyeeW/DCWCbVQAAkWhHrc6ylIbXOY+qK93CuHABV+dNHXK/3V6p4wCSW/+wA==", "license": "MIT", "peerDependencies": { "@babel/runtime": "*", diff --git a/package.json b/package.json index 9650a8225b03..b22c8c5d9f79 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", "@sentry/react-native": "8.2.0", - "@shopify/flash-list": "2.2.0", + "@shopify/flash-list": "^2.3.0", "@shopify/react-native-skia": "^2.4.14", "@ua/react-native-airship": "~25.0.0", "array.prototype.tosorted": "^1.1.4", diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.2.0.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch similarity index 98% rename from patches/@shopify/flash-list/@shopify+flash-list+2.2.0.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch index 007d0682e1cc..e9d9e3dfd981 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.2.0.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -index fb40ded..ea4eba2 100644 +index fb40ded..12375d9 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js @@ -92,6 +92,17 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch new file mode 100644 index 000000000000..8d429b15e332 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch @@ -0,0 +1,154 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index 8b75322..aa740ea 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -2,8 +2,8 @@ + * RecyclerView is a high-performance list component that efficiently renders and recycles list items. + * It's designed to handle large lists with optimal memory usage and smooth scrolling. + */ +-import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; +-import { Animated, I18nManager, } from "react-native"; ++import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; ++import { Animated, I18nManager, Platform, } from "react-native"; + import { ErrorMessages } from "../errors/ErrorMessages"; + import { WarningMessages } from "../errors/WarningMessages"; + import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; +@@ -66,6 +66,50 @@ const RecyclerViewComponent = (props, ref) => { + // Hook to detect when scrolling reaches list bounds + const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); + const isHorizontalRTL = I18nManager.isRTL && horizontal; ++ // Web-only: Fix inverted scroll direction. ++ useEffect(() => { ++ if (!inverted || Platform.OS !== "web") { ++ return; ++ } ++ const scrollRef = scrollViewRef.current; ++ if (!scrollRef || typeof scrollRef.getScrollableNode !== "function") { ++ return; ++ } ++ const node = scrollRef.getScrollableNode(); ++ if (!node) { ++ return; ++ } ++ const wheelHandler = (ev) => { ++ const target = ev.target; ++ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; ++ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; ++ const clientLength = horizontal ? target.clientWidth : target.clientHeight; ++ const isEventTargetScrollable = scrollLength > clientLength; ++ const delta = horizontal ++ ? ev.deltaX || ev.wheelDeltaX || 0 ++ : ev.deltaY || ev.wheelDeltaY || 0; ++ let leftoverDelta = delta; ++ if (isEventTargetScrollable) { ++ leftoverDelta = delta < 0 ++ ? Math.min(delta + scrollOffset, 0) ++ : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0); ++ } ++ const targetDelta = delta - leftoverDelta; ++ if (horizontal) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ } ++ else { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ } ++ ev.preventDefault(); ++ }; ++ node.addEventListener("wheel", wheelHandler, { passive: false }); ++ return () => { ++ node.removeEventListener("wheel", wheelHandler); ++ }; ++ }, [inverted, horizontal]); + /** + * Initialize the RecyclerView by measuring and setting up the window size + * This effect runs when the component mounts or when layout changes +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 34722d4..83a28db 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -5,6 +5,7 @@ + import React, { + RefObject, + useCallback, ++ useEffect, + useLayoutEffect, + useMemo, + useRef, +@@ -17,6 +18,7 @@ import { + I18nManager, + NativeScrollEvent, + NativeSyntheticEvent, ++ Platform, + } from "react-native"; + + import { FlashListRef } from "../FlashListRef"; +@@ -158,6 +160,67 @@ const RecyclerViewComponent = ( + + const isHorizontalRTL = I18nManager.isRTL && horizontal; + ++ /** ++ * Web-only: Fix inverted scroll direction. ++ * When a list is visually inverted via scaleY/scaleX: -1, the browser's native ++ * wheel scroll goes in the wrong visual direction. This effect attaches a wheel ++ * event listener that negates the delta to correct the scroll direction. ++ * Mirrors the fix in react-native-web's VirtualizedList. ++ */ ++ useEffect(() => { ++ if (!inverted || Platform.OS !== "web") { ++ return; ++ } ++ const scrollRef = scrollViewRef.current; ++ if (!scrollRef || typeof (scrollRef as any).getScrollableNode !== "function") { ++ return; ++ } ++ const node = (scrollRef as any).getScrollableNode() as HTMLElement; ++ if (!node) { ++ return; ++ } ++ ++ const wheelHandler = (ev: WheelEvent) => { ++ // Determine scroll metrics for the event target (may be a nested scrollable) ++ const target = ev.target as HTMLElement; ++ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; ++ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; ++ const clientLength = horizontal ? target.clientWidth : target.clientHeight; ++ const isEventTargetScrollable = scrollLength > clientLength; ++ const delta = horizontal ++ ? ev.deltaX || (ev as any).wheelDeltaX || 0 ++ : ev.deltaY || (ev as any).wheelDeltaY || 0; ++ ++ // Calculate how much delta the event target can consume vs leftover for parent ++ let leftoverDelta = delta; ++ if (isEventTargetScrollable) { ++ leftoverDelta = ++ delta < 0 ++ ? Math.min(delta + scrollOffset, 0) ++ : Math.max( ++ delta - (scrollLength - clientLength - scrollOffset), ++ 0 ++ ); ++ } ++ const targetDelta = delta - leftoverDelta; ++ ++ // Apply inverted scroll: negate the delta ++ if (horizontal) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ } else { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ } ++ ev.preventDefault(); ++ }; ++ ++ node.addEventListener("wheel", wheelHandler, { passive: false }); ++ return () => { ++ node.removeEventListener("wheel", wheelHandler); ++ }; ++ }, [inverted, horizontal]); ++ + /** + * Initialize the RecyclerView by measuring and setting up the window size + * This effect runs when the component mounts or when layout changes diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 36c4ae0ae080..dc66fa9407f4 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -1,6 +1,6 @@ # `@shopify/flash-list` patches -### [@shopify+flash-list+2.2.0.patch](@shopify+flash-list+2.2.0.patch) +### [@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch) - Reason: Fixes height normalization in horizontal FlashList when items change. `LinearLayoutManager.normalizeLayoutHeights` had three issues: 1. **Screen resize / item shrink**: When items shrink, `tallestItemHeight` was updated prematurely, causing the next cycle to skip re-normalization. Fixed by resetting tallest item tracking when `targetMinHeight === 0` so the next repaint re-detects the tallest item. @@ -9,3 +9,10 @@ - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/81566 + +### [@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch) + +- Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD From 09217f224b6d962ba022d28ec7d2f069ebebe99a Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 10:21:11 +0100 Subject: [PATCH 02/39] Improve the patch --- ...fix-inverted-scroll-direction-on-web.patch | 77 ++++++++++++++----- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch index 8d429b15e332..123f616e03d8 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch @@ -13,7 +13,7 @@ index 8b75322..aa740ea 100644 import { ErrorMessages } from "../errors/ErrorMessages"; import { WarningMessages } from "../errors/WarningMessages"; import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; -@@ -66,6 +66,50 @@ const RecyclerViewComponent = (props, ref) => { +@@ -66,6 +66,65 @@ const RecyclerViewComponent = (props, ref) => { // Hook to detect when scrolling reaches list bounds const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); const isHorizontalRTL = I18nManager.isRTL && horizontal; @@ -32,13 +32,18 @@ index 8b75322..aa740ea 100644 + } + const wheelHandler = (ev) => { + const target = ev.target; ++ const deltaX = ev.deltaX || ev.wheelDeltaX || 0; ++ const deltaY = ev.deltaY || ev.wheelDeltaY || 0; ++ // Compute scroll limits from the DOM node for overscroll recoil prevention. ++ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; ++ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; ++ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; ++ const isOnScrollLimit = nodeScrollOffset <= 0 || Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; + const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; + const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; + const clientLength = horizontal ? target.clientWidth : target.clientHeight; + const isEventTargetScrollable = scrollLength > clientLength; -+ const delta = horizontal -+ ? ev.deltaX || ev.wheelDeltaX || 0 -+ : ev.deltaY || ev.wheelDeltaY || 0; ++ const delta = horizontal ? deltaX : deltaY; + let leftoverDelta = delta; + if (isEventTargetScrollable) { + leftoverDelta = delta < 0 @@ -47,14 +52,25 @@ index 8b75322..aa740ea 100644 + } + const targetDelta = delta - leftoverDelta; + if (horizontal) { -+ target.scrollLeft += targetDelta; -+ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ if (Math.abs(deltaX) > Math.abs(deltaY)) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } + } + else { -+ target.scrollTop += targetDelta; -+ node.scrollTop = node.scrollTop - leftoverDelta; ++ // Prevent overscroll recoil/rubber band at scroll boundaries. ++ if (isOnScrollLimit && Math.abs(deltaY) > 0) { ++ ev.preventDefault(); ++ } ++ if (Math.abs(deltaY) > Math.abs(deltaX)) { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } + } -+ ev.preventDefault(); + }; + node.addEventListener("wheel", wheelHandler, { passive: false }); + return () => { @@ -84,7 +100,7 @@ index 34722d4..83a28db 100644 } from "react-native"; import { FlashListRef } from "../FlashListRef"; -@@ -158,6 +160,67 @@ const RecyclerViewComponent = ( +@@ -158,6 +160,80 @@ const RecyclerViewComponent = ( const isHorizontalRTL = I18nManager.isRTL && horizontal; @@ -109,15 +125,23 @@ index 34722d4..83a28db 100644 + } + + const wheelHandler = (ev: WheelEvent) => { -+ // Determine scroll metrics for the event target (may be a nested scrollable) + const target = ev.target as HTMLElement; ++ const deltaX = ev.deltaX || (ev as any).wheelDeltaX || 0; ++ const deltaY = ev.deltaY || (ev as any).wheelDeltaY || 0; ++ ++ // Compute scroll limits from the DOM node for overscroll recoil prevention. ++ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; ++ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; ++ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; ++ const isOnScrollLimit = ++ nodeScrollOffset <= 0 || ++ Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; ++ + const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; + const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; + const clientLength = horizontal ? target.clientWidth : target.clientHeight; + const isEventTargetScrollable = scrollLength > clientLength; -+ const delta = horizontal -+ ? ev.deltaX || (ev as any).wheelDeltaX || 0 -+ : ev.deltaY || (ev as any).wheelDeltaY || 0; ++ const delta = horizontal ? deltaX : deltaY; + + // Calculate how much delta the event target can consume vs leftover for parent + let leftoverDelta = delta; @@ -132,15 +156,28 @@ index 34722d4..83a28db 100644 + } + const targetDelta = delta - leftoverDelta; + -+ // Apply inverted scroll: negate the delta ++ // Only adjust scroll and consume the event when the dominant axis ++ // matches the list orientation. stopPropagation prevents parent ++ // inverted lists from also handling this event. + if (horizontal) { -+ target.scrollLeft += targetDelta; -+ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ if (Math.abs(deltaX) > Math.abs(deltaY)) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } + } else { -+ target.scrollTop += targetDelta; -+ node.scrollTop = node.scrollTop - leftoverDelta; ++ // Prevent overscroll recoil/rubber band at scroll boundaries. ++ if (isOnScrollLimit && Math.abs(deltaY) > 0) { ++ ev.preventDefault(); ++ } ++ if (Math.abs(deltaY) > Math.abs(deltaX)) { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } + } -+ ev.preventDefault(); + }; + + node.addEventListener("wheel", wheelHandler, { passive: false }); From 4fe493be99ca93d758a63cedff9c3c92adeeccb3 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 10:37:16 +0100 Subject: [PATCH 03/39] Add a custom FlashList component --- src/components/FlashList/index.tsx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/components/FlashList/index.tsx diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx new file mode 100644 index 000000000000..43a3cdf3efa7 --- /dev/null +++ b/src/components/FlashList/index.tsx @@ -0,0 +1,29 @@ +import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; +import type {FlashListProps} from '@shopify/flash-list'; +import React, {useCallback} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; + +function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll + emitComposerScrollEvents(); + }, + [emitComposerScrollEvents, onScrollProp], + ); + + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} + inverted={inverted} + onScroll={handleScroll} + /> + ); +} + +export default FlashList; From 2e4ff79b4e05b7f09323fc744ccc85cf34b3e41a Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 11:34:45 +0100 Subject: [PATCH 04/39] Fix items display --- .../CellRendererComponent.tsx | 36 +++++++++++++++++++ .../FlashList/InvertedFlashList/index.tsx | 17 +++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx create mode 100644 src/components/FlashList/InvertedFlashList/index.tsx diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx new file mode 100644 index 000000000000..0e5c32f552f5 --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; +import {StyleSheet, View} from 'react-native'; + +type CellRendererComponentProps = ViewProps & { + index: number; + style?: StyleProp; +}; + +function CellRendererComponent(props: CellRendererComponentProps) { + const flattenedStyle = StyleSheet.flatten(props.style); + // FlashList sets `top` for positioning of cells. We omit it here + // because FlashList manages cell positioning internally and the `top` value + // conflicts with the inverted zIndex stacking we apply below. + const {top, ...styleWithoutTop} = flattenedStyle ?? {}; + + return ( + + ); +} + +export default CellRendererComponent; diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx new file mode 100644 index 000000000000..5cb3900e23c8 --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -0,0 +1,17 @@ +import type {FlashListProps} from '@shopify/flash-list'; +import React from 'react'; +import FlashList from '..'; +import CellRendererComponent from './CellRendererComponent'; + +function InvertedFlashList(props: FlashListProps) { + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + inverted + CellRendererComponent={CellRendererComponent} + /> + ); +} + +export default InvertedFlashList; From 286f0065a4619aadbd6260e20f94e64c65ece1c7 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 16:09:37 +0100 Subject: [PATCH 05/39] Add a patch to fix first item offset calculation --- ...0+003+fix-inverted-first-item-offset.patch | 38 +++++++++++++++++++ patches/@shopify/flash-list/details.md | 7 ++++ 2 files changed, 45 insertions(+) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch new file mode 100644 index 000000000000..3dff7ca292a4 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index 8b75322..7a1e3c5 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -78,9 +78,11 @@ const RecyclerViewComponent = (props, ref) => { + containerViewSizeRef.current = outerViewSize; + // firstChildViewLayout is already relative to the outer container, + // so its x/y directly gives the first item offset. +- const firstItemOffset = horizontal +- ? firstChildViewLayout.x +- : firstChildViewLayout.y; ++ const firstItemOffset = inverted ++ ? 0 ++ : horizontal ++ ? firstChildViewLayout.x ++ : firstChildViewLayout.y; + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams({ + width: horizontal ? outerViewSize.width : firstChildViewLayout.width, +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 34722d4..a1b2c3d 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -175,9 +175,11 @@ const RecyclerViewComponent = ( + + // firstChildViewLayout is already relative to the outer container, + // so its x/y directly gives the first item offset. +- const firstItemOffset = horizontal +- ? firstChildViewLayout.x +- : firstChildViewLayout.y; ++ const firstItemOffset = inverted ++ ? 0 ++ : horizontal ++ ? firstChildViewLayout.x ++ : firstChildViewLayout.y; + + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams( diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index dc66fa9407f4..ba050942dc54 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -16,3 +16,10 @@ - Upstream PR/issue: TBD - E/App issue: TBD - PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch) + +- Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD From d56d2fa4ad61becc73cea1a678c09ca2cc681276 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 16:36:06 +0100 Subject: [PATCH 06/39] Fix the patches --- ...+002+fix-inverted-scroll-direction-on-web.patch | 14 +++++++------- ...+2.3.0+003+fix-inverted-first-item-offset.patch | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch index 123f616e03d8..1ec542c467d9 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..aa740ea 100644 +index 8b75322..8ea7795 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js @@ -2,8 +2,8 @@ @@ -13,7 +13,7 @@ index 8b75322..aa740ea 100644 import { ErrorMessages } from "../errors/ErrorMessages"; import { WarningMessages } from "../errors/WarningMessages"; import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; -@@ -66,6 +66,65 @@ const RecyclerViewComponent = (props, ref) => { +@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => { // Hook to detect when scrolling reaches list bounds const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); const isHorizontalRTL = I18nManager.isRTL && horizontal; @@ -81,7 +81,7 @@ index 8b75322..aa740ea 100644 * Initialize the RecyclerView by measuring and setting up the window size * This effect runs when the component mounts or when layout changes diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 34722d4..83a28db 100644 +index 34722d4..ea801d2 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx @@ -5,6 +5,7 @@ @@ -98,12 +98,12 @@ index 34722d4..83a28db 100644 NativeSyntheticEvent, + Platform, } from "react-native"; - + import { FlashListRef } from "../FlashListRef"; -@@ -158,6 +160,80 @@ const RecyclerViewComponent = ( - +@@ -158,6 +160,88 @@ const RecyclerViewComponent = ( + const isHorizontalRTL = I18nManager.isRTL && horizontal; - + + /** + * Web-only: Fix inverted scroll direction. + * When a list is visually inverted via scaleY/scaleX: -1, the browser's native diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch index 3dff7ca292a4..0c535af91eb7 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..7a1e3c5 100644 +index 8ea7795..205069b 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -78,9 +78,11 @@ const RecyclerViewComponent = (props, ref) => { +@@ -138,9 +138,11 @@ const RecyclerViewComponent = (props, ref) => { containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. @@ -18,11 +18,11 @@ index 8b75322..7a1e3c5 100644 recyclerViewManager.updateLayoutParams({ width: horizontal ? outerViewSize.width : firstChildViewLayout.width, diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 34722d4..a1b2c3d 100644 +index ea801d2..8a7deff 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -175,9 +175,11 @@ const RecyclerViewComponent = ( - +@@ -259,9 +259,11 @@ const RecyclerViewComponent = ( + // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. - const firstItemOffset = horizontal @@ -33,6 +33,6 @@ index 34722d4..a1b2c3d 100644 + : horizontal + ? firstChildViewLayout.x + : firstChildViewLayout.y; - + // Update the RecyclerView manager with window dimensions recyclerViewManager.updateLayoutParams( From f48e2ca2f673a21f54ff7c5f7aa9473c4f275bfd Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 16:51:57 +0100 Subject: [PATCH 07/39] Fix CellRendererComponent --- .../InvertedFlashList/CellRendererComponent.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx index 0e5c32f552f5..65397e70a570 100644 --- a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx +++ b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; -import {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; type CellRendererComponentProps = ViewProps & { index: number; @@ -8,18 +8,12 @@ type CellRendererComponentProps = ViewProps & { }; function CellRendererComponent(props: CellRendererComponentProps) { - const flattenedStyle = StyleSheet.flatten(props.style); - // FlashList sets `top` for positioning of cells. We omit it here - // because FlashList manages cell positioning internally and the `top` value - // conflicts with the inverted zIndex stacking we apply below. - const {top, ...styleWithoutTop} = flattenedStyle ?? {}; - return ( ); From 018b32d75261d6cc65213a7e3d34ea4eb983c8a3 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 12 Mar 2026 17:48:41 +0100 Subject: [PATCH 08/39] Update ReportActionsList to use InvertedFlashList --- src/pages/inbox/report/ReportActionsList.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index b483433b3530..6fde46e0e416 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -8,8 +8,8 @@ import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import Button from '@components/Button'; +import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; -import InvertedFlatList from '@components/FlatList/InvertedFlatList'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -908,36 +908,32 @@ function ReportActionsList({ style={[styles.flex1, !shouldShowReportRecipientLocalTime && !hideComposer ? styles.pb4 : {}]} fsClass={reportActionsListFSClass} > - {shouldScrollToEndAfterLayout && topReportAction ? renderTopReportActions() : undefined} - { trackVerticalScrolling(undefined); }} From 197389e12299b2047048190e400fe6c2370acf13 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 18 Mar 2026 15:07:19 +0100 Subject: [PATCH 09/39] Fix chat opening at the linked report action --- .../FlashList/InvertedFlashList/index.tsx | 19 +++++++++-- .../FlashList/useFlashListScrollKey.ts | 33 +++++++++++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 12 +++---- 3 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 src/components/FlashList/useFlashListScrollKey.ts diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 5cb3900e23c8..ed732b49b43f 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -1,14 +1,29 @@ import type {FlashListProps} from '@shopify/flash-list'; import React from 'react'; +import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey'; import FlashList from '..'; import CellRendererComponent from './CellRendererComponent'; -function InvertedFlashList(props: FlashListProps) { +type InvertedFlashListProps = FlashListProps & { + initialScrollKey?: string | null; + data: T[]; + keyExtractor: (item: T, index: number) => string; +}; + +function InvertedFlashList({data, keyExtractor, initialScrollKey, ...restProps}: InvertedFlashListProps) { + const {displayedData} = useFlashListScrollKey({ + data, + keyExtractor, + initialScrollKey, + }); + return ( // eslint-disable-next-line react/jsx-props-no-spreading - {...props} + {...restProps} inverted + data={displayedData} + keyExtractor={keyExtractor} CellRendererComponent={CellRendererComponent} /> ); diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts new file mode 100644 index 000000000000..3ba3fc599902 --- /dev/null +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -0,0 +1,33 @@ +import {useEffect, useState} from 'react'; + +type FlashListScrollKeyProps = { + data: T[]; + keyExtractor: (item: T, index: number) => string; + initialScrollKey: string | null | undefined; +}; + +export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey}: FlashListScrollKeyProps) { + const [isInitialRender, setIsInitialRender] = useState(true); + + // After the first render with sliced data, give FlashList one frame to lay out, + // then switch to the full data array. maintainVisibleContentPosition keeps the target pinned. + useEffect(() => { + if (!isInitialRender || !initialScrollKey) { + return; + } + requestAnimationFrame(() => setIsInitialRender(false)); + }, [isInitialRender, initialScrollKey]); + + if (!isInitialRender || !initialScrollKey) { + return {displayedData: data}; + } + + const targetIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey); + if (targetIndex <= 0) { + return {displayedData: data}; + } + + // On the first render, slice from the target onward so the target item + // appears at the visual bottom of the inverted list — no scrolling needed. + return {displayedData: data.slice(targetIndex)}; +} diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 89ad301081e1..1f4dcf94d736 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -165,8 +165,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } -const onScrollToIndexFailed = () => {}; - function ReportActionsList({ report, transactionThreadReport, @@ -229,14 +227,13 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const backTo = route?.params?.backTo as string; - // Display the new message indicator when comment linking and not close to the newest message. - const reportActionID = route?.params?.reportActionID; + const linkedReportActionID = route?.params?.reportActionID; const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); const topReportAction = sortedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !reportActionID); + const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); const isAnonymousUser = useIsAnonymousUser(); useEffect(() => { @@ -249,7 +246,6 @@ function ReportActionsList({ const readActionSkipped = useRef(false); const hasHeaderRendered = useRef(false); - const linkedReportActionID = route?.params?.reportActionID; const lastAction = sortedVisibleReportActions.at(0); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( @@ -925,7 +921,7 @@ function ReportActionsList({ onStartReachedThreshold={0.75} ListHeaderComponent={listHeaderComponent} ListFooterComponent={listFooterComponent} - maintainVisibleContentPosition={{startRenderingFromBottom: shouldFocusToTopOnMount && !reportActionID}} + maintainVisibleContentPosition={{startRenderingFromBottom: shouldFocusToTopOnMount && !linkedReportActionID}} keyboardShouldPersistTaps="handled" onLayout={onLayoutInner} onScroll={trackVerticalScrolling} @@ -933,7 +929,7 @@ function ReportActionsList({ extraData={extraData} key={listID} // shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScrollToTopThreshold} - // initialScrollKey={reportActionID} + initialScrollKey={linkedReportActionID} onContentSizeChange={() => { trackVerticalScrolling(undefined); }} From b720cdda903dc9190f2c57e9e2d35ac13d3d163e Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 18 Mar 2026 18:21:53 +0100 Subject: [PATCH 10/39] Restore shouldScrollToEndAfterLayout logic --- .../FlashList/InvertedFlashList/index.tsx | 1 + src/components/FlashList/index.native.tsx | 36 +++++++++++++++++++ src/components/FlashList/index.tsx | 9 ++++- src/pages/inbox/report/ReportActionsList.tsx | 4 ++- 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/components/FlashList/index.native.tsx diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index ed732b49b43f..942981f341ce 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -8,6 +8,7 @@ type InvertedFlashListProps = FlashListProps & { initialScrollKey?: string | null; data: T[]; keyExtractor: (item: T, index: number) => string; + shouldHideContent?: boolean; }; function InvertedFlashList({data, keyExtractor, initialScrollKey, ...restProps}: InvertedFlashListProps) { diff --git a/src/components/FlashList/index.native.tsx b/src/components/FlashList/index.native.tsx new file mode 100644 index 000000000000..a8a4ec93a903 --- /dev/null +++ b/src/components/FlashList/index.native.tsx @@ -0,0 +1,36 @@ +import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; +import type {FlashListProps} from '@shopify/flash-list'; +import React, {useCallback} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type CustomFlashListProps = FlashListProps & { + shouldHideContent?: boolean; +}; + +function FlashList({onScroll: onScrollProp, inverted, shouldHideContent = false, contentContainerStyle, ...restProps}: CustomFlashListProps) { + const styles = useThemeStyles(); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll + emitComposerScrollEvents(); + }, + [emitComposerScrollEvents, onScrollProp], + ); + + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} + inverted={inverted} + onScroll={handleScroll} + contentContainerStyle={shouldHideContent ? [contentContainerStyle, shouldHideContent && styles.opacity0] : contentContainerStyle} + /> + ); +} + +export default FlashList; diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index 43a3cdf3efa7..305abb442c75 100644 --- a/src/components/FlashList/index.tsx +++ b/src/components/FlashList/index.tsx @@ -3,8 +3,14 @@ import type {FlashListProps} from '@shopify/flash-list'; import React, {useCallback} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; +import useThemeStyles from '@hooks/useThemeStyles'; -function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { +type CustomFlashListProps = FlashListProps & { + shouldHideContent?: boolean; +}; + +function FlashList({onScroll: onScrollProp, inverted, shouldHideContent = false, contentContainerStyle, ...restProps}: CustomFlashListProps) { + const styles = useThemeStyles(); const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); const handleScroll = useCallback( @@ -22,6 +28,7 @@ function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashLis {...restProps} inverted={inverted} onScroll={handleScroll} + contentContainerStyle={shouldHideContent ? [contentContainerStyle, styles.visibilityHidden] : contentContainerStyle} /> ); } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 1f4dcf94d736..392683b81a81 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -904,6 +904,7 @@ function ReportActionsList({ style={[styles.flex1, !shouldShowReportRecipientLocalTime && !hideComposer ? styles.pb4 : {}]} fsClass={reportActionsListFSClass} > + {shouldScrollToEndAfterLayout && topReportAction ? renderTopReportActions() : undefined} Date: Wed, 18 Mar 2026 18:30:50 +0100 Subject: [PATCH 11/39] Add TODOs --- src/pages/inbox/report/ReportActionsList.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 392683b81a81..5d48e666e43f 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,7 +1,7 @@ -import type {ListRenderItemInfo} from '@react-native/virtualized-lists'; import {useIsFocused, useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; +import type {ListRenderItemInfo} from '@shopify/flash-list'; import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; @@ -120,6 +120,8 @@ type ReportActionsListProps = { listID: number; /** Should enable auto scroll to top threshold */ + // TODO: check if we still need it + // eslint-disable-next-line react/no-unused-prop-types shouldEnableAutoScrollToTopThreshold?: boolean; /** Whether the optimistic CREATED report action was added */ @@ -178,7 +180,7 @@ function ReportActionsList({ onLayout, isComposerFullSize, listID, - shouldEnableAutoScrollToTopThreshold, + // shouldEnableAutoScrollToTopThreshold, parentReportActionForTransactionThread, hasCreatedActionAdded, isConciergeSidePanel, @@ -907,6 +909,7 @@ function ReportActionsList({ {shouldScrollToEndAfterLayout && topReportAction ? renderTopReportActions() : undefined} Date: Thu, 19 Mar 2026 11:49:25 +0100 Subject: [PATCH 12/39] Fix items overlapping after chat initial loading finished --- ...nding-children-blocking-measurements.patch | 68 +++++++++++++++++++ patches/@shopify/flash-list/details.md | 7 ++ 2 files changed, 75 insertions(+) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch new file mode 100644 index 000000000000..75777a43d577 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch @@ -0,0 +1,68 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index 205069b..7819c8b 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -162,9 +162,6 @@ const RecyclerViewComponent = (props, ref) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + var _a, _b; +- if (pendingChildIds.size > 0) { +- return; +- } + const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { + const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index)); + // comapre height with stored layout +@@ -188,8 +185,17 @@ const RecyclerViewComponent = (props, ref) => { + } + if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && + !hasExceededMaxRendersWithoutCommit) { +- // Trigger re-render if layout modifications were made +- setRenderId((prev) => prev + 1); ++ if (pendingChildIds.size > 0) { ++ // When child FlashLists are still loading, avoid triggering a full ++ // RecyclerView re-render (setRenderId) to prevent cascading setState ++ // calls that could cause "Maximum update depth exceeded" errors. ++ // Instead, just commit the layout to update item positions in ++ // ViewHolderCollection without re-measuring. ++ (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); ++ } else { ++ // Trigger re-render if layout modifications were made ++ setRenderId((prev) => prev + 1); ++ } + } + else { + (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 8a7deff..b2bd67a 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -287,9 +287,6 @@ const RecyclerViewComponent = ( + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { +- if (pendingChildIds.size > 0) { +- return; +- } + const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { + const layout = measureItemLayout( + viewHolderRef.current!, +@@ -323,8 +320,17 @@ const RecyclerViewComponent = ( + recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && + !hasExceededMaxRendersWithoutCommit + ) { +- // Trigger re-render if layout modifications were made +- setRenderId((prev) => prev + 1); ++ if (pendingChildIds.size > 0) { ++ // When child FlashLists are still loading, avoid triggering a full ++ // RecyclerView re-render (setRenderId) to prevent cascading setState ++ // calls that could cause "Maximum update depth exceeded" errors. ++ // Instead, just commit the layout to update item positions in ++ // ViewHolderCollection without re-measuring. ++ viewHolderCollectionRef.current?.commitLayout(); ++ } else { ++ // Trigger re-render if layout modifications were made ++ setRenderId((prev) => prev + 1); ++ } + } else { + viewHolderCollectionRef.current?.commitLayout(); + applyOffsetCorrection(); diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index ba050942dc54..7b0a81e895d4 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -23,3 +23,10 @@ - Upstream PR/issue: TBD - E/App issue: TBD - PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch) + +- Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD From 6362b592a5c23f0705f0d518f9032ec499c117a0 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 19 Mar 2026 16:29:02 +0100 Subject: [PATCH 13/39] Remove shouldEnableAutoScrollToTopThreshold --- src/pages/inbox/report/ReportActionsList.tsx | 7 ---- src/pages/inbox/report/ReportActionsView.tsx | 40 +------------------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index dc61a02b977c..2ae0650184ba 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -119,11 +119,6 @@ type ReportActionsListProps = { /** ID of the list */ listID: number; - /** Should enable auto scroll to top threshold */ - // TODO: check if we still need it - // eslint-disable-next-line react/no-unused-prop-types - shouldEnableAutoScrollToTopThreshold?: boolean; - /** Whether the optimistic CREATED report action was added */ hasCreatedActionAdded?: boolean; @@ -180,7 +175,6 @@ function ReportActionsList({ onLayout, isComposerFullSize, listID, - // shouldEnableAutoScrollToTopThreshold, parentReportActionForTransactionThread, hasCreatedActionAdded, isConciergeSidePanel, @@ -933,7 +927,6 @@ function ReportActionsList({ onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} key={listID} - // shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScrollToTopThreshold} initialScrollKey={linkedReportActionID} onContentSizeChange={() => { trackVerticalScrolling(undefined); diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 605caa66ef4b..ef1814c8ec3a 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -1,6 +1,5 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager} from 'react-native'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; @@ -142,7 +141,6 @@ function ReportActionsView({ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const prevTransactionThreadReport = usePrevious(transactionThreadReport); const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); const reportPreviewAction = useMemo(() => getReportPreviewAction(report.chatReportID, report.reportID), [report.chatReportID, report.reportID]); @@ -151,7 +149,6 @@ function ReportActionsView({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const isFocused = useIsFocused(); - const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(false); const prevShouldUseNarrowLayoutRef = useRef(shouldUseNarrowLayout); const reportID = report.reportID; const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]); @@ -281,12 +278,7 @@ function ReportActionsView({ [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, visibleReportActionsData, reportID], ); - const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); - const lastActionCreated = visibleReportActions.at(0)?.created; - const isNewestAction = (actionCreated: string | undefined, lastVisibleActionCreated: string | undefined) => - actionCreated && lastVisibleActionCreated ? actionCreated >= lastVisibleActionCreated : actionCreated === lastVisibleActionCreated; - const hasNewestReportAction = isNewestAction(lastActionCreated, report.lastVisibleActionCreated) || isNewestAction(lastActionCreated, transactionThreadReport?.lastVisibleActionCreated); const isSingleExpenseReport = reportPreviewAction?.childMoneyRequestCount === 1; const isMissingTransactionThreadReportID = !transactionThreadReport?.reportID; @@ -350,33 +342,6 @@ function ReportActionsView({ [report, onLayout], ); - // Check if the first report action in the list is the one we're currently linked to - const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; - - useEffect(() => { - let timerID: NodeJS.Timeout; - - if (!isTheFirstReportActionIsLinked && reportActionID) { - setNavigatingToLinkedMessage(true); - // After navigating to the linked reportAction, apply this to correctly set - // `autoscrollToTopThreshold` prop when linking to a specific reportAction. - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - // Using a short delay to ensure the view is updated after interactions - timerID = setTimeout(() => setNavigatingToLinkedMessage(false), 10); - }); - } else { - setNavigatingToLinkedMessage(false); - } - - return () => { - if (!timerID) { - return; - } - clearTimeout(timerID); - }; - }, [isTheFirstReportActionIsLinked, reportActionID]); - // Show skeleton while loading initial report actions when data is incomplete/missing and online const shouldShowSkeletonForInitialLoad = isLoadingInitialReportActions && (isReportDataIncomplete || isMissingReportActions) && !isOffline; @@ -408,8 +373,6 @@ function ReportActionsView({ return ; } - // AutoScroll is disabled when we do linking to a specific reportAction - const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); return ( <> Date: Fri, 20 Mar 2026 19:08:15 +0100 Subject: [PATCH 14/39] Re-apply patches --- ...ash-list+2.3.0+003+skip-layout-when-hidden.patch} | 6 +++--- ...t+2.3.0+004+fix-inverted-first-item-offset.patch} | 4 ++-- ...fix-pending-children-blocking-measurements.patch} | 12 ++++++------ patches/@shopify/flash-list/details.md | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch => @shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch} (90%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch => @shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch} (95%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch => @shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch} (89%) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch similarity index 90% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch index d8cf7aec869e..731cdf908fba 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..dd2d3bc 100644 +index 8ea7795..d7a3d84 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -134,6 +134,10 @@ const RecyclerViewComponent = (props, ref) => { if (internalViewRef.current && firstChildViewRef.current) { // Measure the outer container size and inner container layout const outerViewSize = measureParentSize(internalViewRef.current); @@ -13,7 +13,7 @@ index 8b75322..dd2d3bc 100644 const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current); containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, -@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -163,6 +167,10 @@ const RecyclerViewComponent = (props, ref) => { if (pendingChildIds.size > 0) { return; } diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch similarity index 95% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch index 0c535af91eb7..98bac124be64 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8ea7795..205069b 100644 +index d7a3d84..ffcdad8 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -138,9 +138,11 @@ const RecyclerViewComponent = (props, ref) => { +@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => { containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch similarity index 89% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch index 75777a43d577..6b96845c5875 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch @@ -1,18 +1,18 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 205069b..7819c8b 100644 +index ffcdad8..ee42f63 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -162,9 +162,6 @@ const RecyclerViewComponent = (props, ref) => { +@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => { // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { var _a, _b; - if (pendingChildIds.size > 0) { - return; - } - const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { - const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index)); - // comapre height with stored layout -@@ -188,8 +185,17 @@ const RecyclerViewComponent = (props, ref) => { + if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && + ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { + return; +@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => { } if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && !hasExceededMaxRendersWithoutCommit) { diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 0bfd9a3a5937..e8c88d64093e 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -21,21 +21,21 @@ - E/App issue: https://github.com/Expensify/App/issues/83976 - PR introducing patch: https://github.com/Expensify/App/pull/84887 -### [@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch) +### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch) - Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. - Upstream PR/issue: TBD - E/App issue: TBD - PR introducing patch: TBD -### [@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch) +### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) - Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. - Upstream PR/issue: TBD - E/App issue: TBD - PR introducing patch: TBD -### [@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+004+fix-pending-children-blocking-measurements.patch) +### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) - Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. - Upstream PR/issue: TBD From c7e2cf5b0ec77d62a0ee8dc42b344c29eeedeccc Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 20 Mar 2026 19:21:08 +0100 Subject: [PATCH 15/39] Re-apply patches - correct order --- ...pify+flash-list+2.3.0+002+skip-layout-when-hidden.patch} | 6 +++--- ...st+2.3.0+003+fix-inverted-scroll-direction-on-web.patch} | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch => @shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch} (90%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch => @shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch} (99%) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch similarity index 90% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch index 731cdf908fba..d8cf7aec869e 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+skip-layout-when-hidden.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8ea7795..d7a3d84 100644 +index 8b75322..dd2d3bc 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -134,6 +134,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => { if (internalViewRef.current && firstChildViewRef.current) { // Measure the outer container size and inner container layout const outerViewSize = measureParentSize(internalViewRef.current); @@ -13,7 +13,7 @@ index 8ea7795..d7a3d84 100644 const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current); containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, -@@ -163,6 +167,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => { if (pendingChildIds.size > 0) { return; } diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch similarity index 99% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch index 1ec542c467d9..edb436a356b4 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+fix-inverted-scroll-direction-on-web.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..8ea7795 100644 +index dd2d3bc..d7a3d84 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js @@ -2,8 +2,8 @@ From 87bedd8d404d70312b8c2770eb0fa09890264431 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Mar 2026 11:44:04 +0100 Subject: [PATCH 16/39] Fix PaginationTest.tsx --- tests/ui/PaginationTest.tsx | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index e57d7cfc3f70..adda1050c69c 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -29,6 +29,31 @@ jest.mock('../../src/libs/Notification/LocalNotification'); jest.mock('../../src/components/Icon/Expensicons'); jest.mock('../../src/components/ConfirmedRoute.tsx'); jest.mock('@libs/Navigation/AppNavigator/usePreloadFullScreenNavigators', () => jest.fn()); +jest.mock( + '@shopify/flash-list/dist/recyclerview/utils/measureLayout', + () => + ({ + ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), + measureParentSize: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureFirstChildLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureItemLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 75, + })), + }) as Record, +); TestHelper.setupApp(); const fetchMock = TestHelper.setupGlobalFetchMock(); @@ -394,7 +419,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); From 75abf62d25e1e58076f7fc34dcc87dda1cf87c19 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Mar 2026 12:20:04 +0100 Subject: [PATCH 17/39] Return test to initial state --- cspell.json | 1 + tests/ui/PaginationTest.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cspell.json b/cspell.json index 3fa504ae25a9..0c82a040cde2 100644 --- a/cspell.json +++ b/cspell.json @@ -643,6 +643,7 @@ "RPID", "RRGGBB", "rstrip", + "recyclerview", "RTER", "s3uqn2oe4m85tufi6mqflbfbuajrm2i3", "SAASPASS", diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index adda1050c69c..9032cf4e133f 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -419,7 +419,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); From 83e022e18f1489780eeced4a34af649957d2459d Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Mar 2026 14:08:27 +0100 Subject: [PATCH 18/39] Fix --- tests/ui/PaginationTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 9032cf4e133f..adda1050c69c 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -419,7 +419,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); From 90b3623fd3583931ae738eb96a17285dce62207b Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Mar 2026 14:24:26 +0100 Subject: [PATCH 19/39] Revert "Fix" This reverts commit 83e022e18f1489780eeced4a34af649957d2459d. --- tests/ui/PaginationTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index adda1050c69c..9032cf4e133f 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -419,7 +419,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); From 27e29220631f130b8f9d1fe81bbace492fe441b4 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Mar 2026 16:00:18 +0100 Subject: [PATCH 20/39] Add mock to ReportActionsList.perf-test.tsx --- .../perf-test/ReportActionsList.perf-test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 72122f8bf02a..e3f294d870e2 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -60,6 +60,32 @@ jest.mock('@react-navigation/native', () => { jest.mock('@src/components/ConfirmedRoute.tsx'); +jest.mock( + '@shopify/flash-list/dist/recyclerview/utils/measureLayout', + () => + ({ + ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), + measureParentSize: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureFirstChildLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureItemLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 75, + })), + }) as Record, +); + beforeAll(() => Onyx.init({ keys: ONYXKEYS, From 3bd3673b32e139c1e0c5ed1003aa2dcc2c522f25 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Mar 2026 16:13:43 +0100 Subject: [PATCH 21/39] Experiment --- tests/ui/PaginationTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 9032cf4e133f..adda1050c69c 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -419,7 +419,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); From 1c4f9f21bf73e07395c85f396dac1314fae63963 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 1 Apr 2026 09:56:58 +0200 Subject: [PATCH 22/39] Clean up after conflicts resolution --- src/pages/inbox/report/ReportActionsView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 8f00cde4f05d..10af07438de3 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -140,7 +140,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); const reportPreviewAction = useMemo(() => getReportPreviewAction(report?.chatReportID, report?.reportID), [report?.chatReportID, report?.reportID]); const didLayout = useRef(false); From 97b25568f290508ca79338271f4c9bfd1ae971d5 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 1 Apr 2026 11:40:21 +0200 Subject: [PATCH 23/39] Get rid of shouldHideContent flag --- .../FlashList/InvertedFlashList/index.tsx | 1 - src/components/FlashList/index.native.tsx | 36 ------------------- src/components/FlashList/index.tsx | 24 ++++--------- src/pages/inbox/report/ReportActionsList.tsx | 9 +++-- .../index.native.tsx | 5 +++ .../utils/getHiddenChatContentStyle/index.ts | 5 +++ .../utils/getHiddenChatContentStyle/types.ts | 6 ++++ src/styles/utils/index.ts | 2 ++ 8 files changed, 32 insertions(+), 56 deletions(-) delete mode 100644 src/components/FlashList/index.native.tsx create mode 100644 src/styles/utils/getHiddenChatContentStyle/index.native.tsx create mode 100644 src/styles/utils/getHiddenChatContentStyle/index.ts create mode 100644 src/styles/utils/getHiddenChatContentStyle/types.ts diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 942981f341ce..ed732b49b43f 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -8,7 +8,6 @@ type InvertedFlashListProps = FlashListProps & { initialScrollKey?: string | null; data: T[]; keyExtractor: (item: T, index: number) => string; - shouldHideContent?: boolean; }; function InvertedFlashList({data, keyExtractor, initialScrollKey, ...restProps}: InvertedFlashListProps) { diff --git a/src/components/FlashList/index.native.tsx b/src/components/FlashList/index.native.tsx deleted file mode 100644 index a8a4ec93a903..000000000000 --- a/src/components/FlashList/index.native.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; -import type {FlashListProps} from '@shopify/flash-list'; -import React, {useCallback} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; -import useThemeStyles from '@hooks/useThemeStyles'; - -type CustomFlashListProps = FlashListProps & { - shouldHideContent?: boolean; -}; - -function FlashList({onScroll: onScrollProp, inverted, shouldHideContent = false, contentContainerStyle, ...restProps}: CustomFlashListProps) { - const styles = useThemeStyles(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); - - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); - - return ( - - // eslint-disable-next-line react/jsx-props-no-spreading - {...restProps} - inverted={inverted} - onScroll={handleScroll} - contentContainerStyle={shouldHideContent ? [contentContainerStyle, shouldHideContent && styles.opacity0] : contentContainerStyle} - /> - ); -} - -export default FlashList; diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index 305abb442c75..888821e8d1a3 100644 --- a/src/components/FlashList/index.tsx +++ b/src/components/FlashList/index.tsx @@ -1,26 +1,17 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; import type {FlashListProps} from '@shopify/flash-list'; -import React, {useCallback} from 'react'; +import React from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; -import useThemeStyles from '@hooks/useThemeStyles'; -type CustomFlashListProps = FlashListProps & { - shouldHideContent?: boolean; -}; - -function FlashList({onScroll: onScrollProp, inverted, shouldHideContent = false, contentContainerStyle, ...restProps}: CustomFlashListProps) { - const styles = useThemeStyles(); +function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); + const handleScroll = (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll + emitComposerScrollEvents(); + }; return ( @@ -28,7 +19,6 @@ function FlashList({onScroll: onScrollProp, inverted, shouldHideContent = fal {...restProps} inverted={inverted} onScroll={handleScroll} - contentContainerStyle={shouldHideContent ? [contentContainerStyle, styles.visibilityHidden] : contentContainerStyle} /> ); } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index bae599f7acbb..0210ba37b789 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -22,6 +22,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isSafari} from '@libs/Browser'; @@ -178,6 +179,7 @@ function ReportActionsList({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const personalDetailsList = usePersonalDetails(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow']); const {windowHeight} = useWindowDimensions(); @@ -874,8 +876,11 @@ function ReportActionsList({ keyExtractor={keyExtractor} drawDistance={1500} renderScrollComponent={renderActionSheetAwareScrollView} - contentContainerStyle={[styles.chatContentScrollView, shouldFocusToTopOnMount ? styles.justifyContentEnd : undefined]} - shouldHideContent={shouldScrollToEndAfterLayout} + contentContainerStyle={[ + styles.chatContentScrollView, + shouldFocusToTopOnMount && styles.justifyContentEnd, + shouldScrollToEndAfterLayout && StyleUtils.getHiddenChatContentStyle(), + ]} showsVerticalScrollIndicator={!shouldScrollToEndAfterLayout} onEndReached={onEndReached} onEndReachedThreshold={0.75} diff --git a/src/styles/utils/getHiddenChatContentStyle/index.native.tsx b/src/styles/utils/getHiddenChatContentStyle/index.native.tsx new file mode 100644 index 000000000000..5d9275b35501 --- /dev/null +++ b/src/styles/utils/getHiddenChatContentStyle/index.native.tsx @@ -0,0 +1,5 @@ +import type GetHiddenChatContentStyle from './types'; + +const getHiddenChatContentStyle: GetHiddenChatContentStyle = (styles) => styles.opacity0; + +export default getHiddenChatContentStyle; diff --git a/src/styles/utils/getHiddenChatContentStyle/index.ts b/src/styles/utils/getHiddenChatContentStyle/index.ts new file mode 100644 index 000000000000..d50dd359542c --- /dev/null +++ b/src/styles/utils/getHiddenChatContentStyle/index.ts @@ -0,0 +1,5 @@ +import type GetHiddenChatContentStyle from './types'; + +const getHiddenChatContentStyle: GetHiddenChatContentStyle = (styles) => styles.visibilityHidden; + +export default getHiddenChatContentStyle; diff --git a/src/styles/utils/getHiddenChatContentStyle/types.ts b/src/styles/utils/getHiddenChatContentStyle/types.ts new file mode 100644 index 000000000000..f9159f68a4f6 --- /dev/null +++ b/src/styles/utils/getHiddenChatContentStyle/types.ts @@ -0,0 +1,6 @@ +import type {ViewStyle} from 'react-native'; +import type {ThemeStyles} from '@src/styles'; + +type GetHiddenChatContentStyle = (styles: ThemeStyles) => ViewStyle; + +export default GetHiddenChatContentStyle; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 1cac4503552e..588f3a4a9d81 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -27,6 +27,7 @@ import createModalStyleUtils from './generators/ModalStyleUtils'; import createReportActionContextMenuStyleUtils from './generators/ReportActionContextMenuStyleUtils'; import createTooltipStyleUtils from './generators/TooltipStyleUtils'; import getContextMenuItemStyles from './getContextMenuItemStyles'; +import getHiddenChatContentStyle from './getHiddenChatContentStyle'; import getHighResolutionInfoWrapperStyle from './getHighResolutionInfoWrapperStyle'; import getMoneyRequestReportPreviewStyle from './getMoneyRequestReportPreviewStyle'; import getNavigationBarType from './getNavigationBarType/index'; @@ -1415,6 +1416,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ getCompactContentContainerStyles: () => compactContentContainerStyles(styles), getContextMenuItemStyles: (windowWidth?: number) => getContextMenuItemStyles(styles, windowWidth), getContainerComposeStyles: () => containerComposeStyles(styles), + getHiddenChatContentStyle: () => getHiddenChatContentStyle(styles), /** * Gets styles for AutoCompleteSuggestion row From ae4beef2cfd37fd5e227b3eae60bbc11c636c02a Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 1 Apr 2026 12:17:54 +0200 Subject: [PATCH 24/39] Fix --- tests/ui/PaginationTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index b21bc8c3f4a2..d913b7f3fee7 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -419,7 +419,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); From 408b4880eca6119f3b78d1886dc953e61221caa9 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 1 Apr 2026 14:45:16 +0200 Subject: [PATCH 25/39] Fix jest test --- tests/ui/PaginationTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index d913b7f3fee7..0f9aa9f15e3c 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -397,6 +397,7 @@ describe('Pagination', () => { scrollToOffset(0); // ReportScreen relies on the onLayout event to receive updates from onyx. triggerListLayout(); + await waitForNetworkPromises(); await waitForBatchedUpdatesWithAct(); // Here we have 5 messages from the initial OpenReport and 5 from the initial GetNewerActions. From ec435f729bb0a3edc3f5aac8016035f4046912e9 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 1 Apr 2026 15:06:08 +0200 Subject: [PATCH 26/39] Put mock in one place --- jest/setupAfterEnv.ts | 29 +++++++++++++++++++ .../perf-test/ReportActionsList.perf-test.tsx | 26 ----------------- tests/ui/PaginationTest.tsx | 25 ---------------- 3 files changed, 29 insertions(+), 51 deletions(-) diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index c9ef9afc5d98..c074877a3f02 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -4,6 +4,35 @@ import ONYXKEYS from '@src/ONYXKEYS'; jest.useRealTimers(); +// This mock must live in setupAfterEnv (not setupFiles) because @shopify/flash-list/jestSetup, +// imported in setup.ts, registers its own measureLayout mock. Placing ours here ensures it +// runs after FlashList's setup and takes precedence. +jest.mock( + '@shopify/flash-list/dist/recyclerview/utils/measureLayout', + () => + ({ + ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), + measureParentSize: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureFirstChildLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureItemLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 75, + })), + }) as Record, +); + // Auto-initialize Onyx for tests. // Tests that already call Onyx.init() in their own beforeAll will safely re-configure Onyx — // the second init() just re-runs initStoreValues and re-resolves the already-resolved deferred task. diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index e3f294d870e2..72122f8bf02a 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -60,32 +60,6 @@ jest.mock('@react-navigation/native', () => { jest.mock('@src/components/ConfirmedRoute.tsx'); -jest.mock( - '@shopify/flash-list/dist/recyclerview/utils/measureLayout', - () => - ({ - ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), - measureParentSize: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureFirstChildLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureItemLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 75, - })), - }) as Record, -); - beforeAll(() => Onyx.init({ keys: ONYXKEYS, diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 0f9aa9f15e3c..32989fc5d4c3 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -28,31 +28,6 @@ jest.mock('@react-navigation/native'); jest.mock('../../src/libs/Notification/LocalNotification'); jest.mock('../../src/components/ConfirmedRoute.tsx'); jest.mock('@libs/Navigation/AppNavigator/usePreloadFullScreenNavigators', () => jest.fn()); -jest.mock( - '@shopify/flash-list/dist/recyclerview/utils/measureLayout', - () => - ({ - ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), - measureParentSize: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureFirstChildLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureItemLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 75, - })), - }) as Record, -); TestHelper.setupApp(); const fetchMock = TestHelper.setupGlobalFetchMock(); From 56831c60bb66a31401f18ed2b46d0354b46b8d75 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 3 Apr 2026 16:08:55 +0200 Subject: [PATCH 27/39] Resolve TODO --- src/components/FlashList/InvertedFlashList/index.tsx | 2 ++ src/pages/inbox/report/ReportActionsList.tsx | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index ed732b49b43f..1f13ed41057d 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -1,6 +1,7 @@ import type {FlashListProps} from '@shopify/flash-list'; import React from 'react'; import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey'; +import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; import FlashList from '..'; import CellRendererComponent from './CellRendererComponent'; @@ -8,6 +9,7 @@ type InvertedFlashListProps = FlashListProps & { initialScrollKey?: string | null; data: T[]; keyExtractor: (item: T, index: number) => string; + ref: FlatListRefType; }; function InvertedFlashList({data, keyExtractor, initialScrollKey, ...restProps}: InvertedFlashListProps) { diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index d6938feb6b85..542442094698 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -867,7 +867,6 @@ function ReportActionsList({ {shouldScrollToEndAfterLayout && topReportAction ? renderTopReportActions() : undefined} Date: Fri, 3 Apr 2026 16:29:38 +0200 Subject: [PATCH 28/39] Add getItemType prop --- src/pages/inbox/report/ReportActionsList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 542442094698..ea25a2651f01 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -893,6 +893,7 @@ function ReportActionsList({ onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} key={listID} + getItemType={(item) => item.actionName} initialScrollKey={linkedReportActionID} onContentSizeChange={() => { trackVerticalScrolling(undefined); From 281a0ea26b2e0ce9adecd22fd407c054d7dd1722 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 3 Apr 2026 17:33:40 +0200 Subject: [PATCH 29/39] Fix jump when open a chat for the first time at linked position in the middle (of a huge chat) --- src/components/FlashList/InvertedFlashList/index.tsx | 6 ++++-- src/components/FlashList/useFlashListScrollKey.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 1f13ed41057d..2438d1695177 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -12,11 +12,12 @@ type InvertedFlashListProps = FlashListProps & { ref: FlatListRefType; }; -function InvertedFlashList({data, keyExtractor, initialScrollKey, ...restProps}: InvertedFlashListProps) { - const {displayedData} = useFlashListScrollKey({ +function InvertedFlashList({data, keyExtractor, initialScrollKey, onStartReached: onStartReachedProp, ...restProps}: InvertedFlashListProps) { + const {displayedData, onStartReached} = useFlashListScrollKey({ data, keyExtractor, initialScrollKey, + onStartReached: onStartReachedProp, }); return ( @@ -24,6 +25,7 @@ function InvertedFlashList({data, keyExtractor, initialScrollKey, ...restProp // eslint-disable-next-line react/jsx-props-no-spreading {...restProps} inverted + onStartReached={onStartReached} data={displayedData} keyExtractor={keyExtractor} CellRendererComponent={CellRendererComponent} diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index 3ba3fc599902..505268dfaa96 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -1,12 +1,14 @@ +import type {FlashListProps} from '@shopify/flash-list'; import {useEffect, useState} from 'react'; type FlashListScrollKeyProps = { data: T[]; keyExtractor: (item: T, index: number) => string; initialScrollKey: string | null | undefined; + onStartReached: FlashListProps['onStartReached']; }; -export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey}: FlashListScrollKeyProps) { +export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey, onStartReached}: FlashListScrollKeyProps) { const [isInitialRender, setIsInitialRender] = useState(true); // After the first render with sliced data, give FlashList one frame to lay out, @@ -19,15 +21,15 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr }, [isInitialRender, initialScrollKey]); if (!isInitialRender || !initialScrollKey) { - return {displayedData: data}; + return {displayedData: data, onStartReached}; } const targetIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey); if (targetIndex <= 0) { - return {displayedData: data}; + return {displayedData: data, onStartReached}; } // On the first render, slice from the target onward so the target item // appears at the visual bottom of the inverted list — no scrolling needed. - return {displayedData: data.slice(targetIndex)}; + return {displayedData: data.slice(targetIndex), onStartReached: () => {}}; } From a416eb309efc8e972bc89f7a189ac5cfae069a09 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 8 Apr 2026 11:41:39 +0200 Subject: [PATCH 30/39] Fix file name --- .../{index.native.tsx => index.native.ts} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename src/styles/utils/getHiddenChatContentStyle/{index.native.tsx => index.native.ts} (79%) diff --git a/src/styles/utils/getHiddenChatContentStyle/index.native.tsx b/src/styles/utils/getHiddenChatContentStyle/index.native.ts similarity index 79% rename from src/styles/utils/getHiddenChatContentStyle/index.native.tsx rename to src/styles/utils/getHiddenChatContentStyle/index.native.ts index 5d9275b35501..f2595561c84f 100644 --- a/src/styles/utils/getHiddenChatContentStyle/index.native.tsx +++ b/src/styles/utils/getHiddenChatContentStyle/index.native.ts @@ -1,5 +1,7 @@ import type GetHiddenChatContentStyle from './types'; -const getHiddenChatContentStyle: GetHiddenChatContentStyle = (styles) => styles.opacity0; +const getHiddenChatContentStyle: GetHiddenChatContentStyle = (styles) => { + return styles.opacity0; +}; export default getHiddenChatContentStyle; From ba380e077c081dc6164a5c56f22893499de392d1 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 8 Apr 2026 18:04:28 +0200 Subject: [PATCH 31/39] Add patch to fix maintainVisibleContentPosition on Android inverted lists --- ...+2.3.0+006+fix-inverted-mvcp-android.patch | 47 +++++++++++++++++++ patches/@shopify/flash-list/details.md | 7 +++ 2 files changed, 54 insertions(+) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch new file mode 100644 index 000000000000..f68413dc99e0 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch @@ -0,0 +1,47 @@ +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 70f856a..ed4ecfa 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +@@ -1,5 +1,5 @@ + import { useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react"; +-import { I18nManager } from "react-native"; ++import { I18nManager, Platform } from "react-native"; + import { adjustOffsetForRTL } from "../utils/adjustOffsetForRTL"; + import { PlatformConfig } from "../../native/config/PlatformHelper"; + import { WarningMessages } from "../../errors/WarningMessages"; +@@ -79,7 +79,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + */ + const applyOffsetCorrection = useCallback(() => { + var _a, _b, _c; +- const { horizontal, data } = recyclerViewManager.props; ++ const { horizontal, data, inverted } = recyclerViewManager.props; + // Execute all pending callbacks from previous scroll offset updates + // This ensures any scroll operations that were waiting for render are completed + const callbacks = pendingScrollCallbacks.current; +@@ -115,10 +115,24 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + !pauseOffsetCorrection.current && + !recyclerViewManager.animationOptimizationsEnabled) { + // console.log("diff", diff, firstVisibleItemKey.current); +- if (PlatformConfig.supportsOffsetCorrection) { +- // console.log("scrollBy", diff); ++ var useAndroidInvertedFallback = hasDataChanged && inverted && Platform.OS === 'android'; ++ if (PlatformConfig.supportsOffsetCorrection && !useAndroidInvertedFallback) { + (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff); + } ++ else if (useAndroidInvertedFallback) { ++ const scrollToParams = horizontal ++ ? { ++ x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ animated: false, ++ } ++ : { ++ y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ animated: false, ++ }; ++ requestAnimationFrame(() => { ++ (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); ++ }); ++ } + else { + const scrollToParams = horizontal + ? { diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index e8c88d64093e..c3e1cedd8558 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -41,3 +41,10 @@ - Upstream PR/issue: TBD - E/App issue: TBD - PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch) + +- Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component — an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: TBD From ba46555c3c88e9821d89bcc193a9ebf530c8254d Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 8 Apr 2026 18:14:34 +0200 Subject: [PATCH 32/39] Fix jumps on transactions switching --- src/pages/inbox/report/ReportActionsList.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index bde948afa4ac..e66683b42755 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -217,6 +217,7 @@ function ReportActionsList({ const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); const topReportAction = sortedVisibleReportActions.at(-1); const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); + const isScrollEndPendingRef = useRef(false); const isAnonymousUser = useIsAnonymousUser(); useEffect(() => { @@ -352,8 +353,14 @@ function ReportActionsList({ onTrackScrolling: (event: NativeSyntheticEvent) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; onScroll?.(event); - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { - setShouldScrollToEndAfterLayout(false); + // We use a timeout to wait for the scroll to finish before resetting the flag. + // onMomentumScrollEnd would be ideal but it doesn't work on web. + if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline) && !isScrollEndPendingRef.current) { + isScrollEndPendingRef.current = true; + setTimeout(() => { + setShouldScrollToEndAfterLayout(false); + isScrollEndPendingRef.current = false; + }, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME); } }, hasOnceLoadedReportActions: !!reportMetadata?.hasOnceLoadedReportActions, From 9633ab6b57b7eeff531018f23daa30d7365e16be Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 9 Apr 2026 10:08:02 +0200 Subject: [PATCH 33/39] Remove not used anymore files --- jest/setup.ts | 2 +- .../CellRendererComponent.tsx | 30 ---------- .../FlatList/InvertedFlatList/index.tsx | 59 ------------------- .../index.native.ts | 6 -- .../shouldRemoveClippedSubviews/index.ts | 1 - .../FlatList/InvertedFlatList/types.ts | 14 ----- .../RenderTaskQueue.tsx | 0 .../FlatList/hooks/useFlatListScrollKey.ts | 2 +- tests/unit/RenderTaskQueueTest.ts | 4 +- 9 files changed, 4 insertions(+), 114 deletions(-) delete mode 100644 src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx delete mode 100644 src/components/FlatList/InvertedFlatList/index.tsx delete mode 100644 src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts delete mode 100644 src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts delete mode 100644 src/components/FlatList/InvertedFlatList/types.ts rename src/components/FlatList/{InvertedFlatList => }/RenderTaskQueue.tsx (100%) diff --git a/jest/setup.ts b/jest/setup.ts index 3625e9138a50..7c39b6209b11 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -262,7 +262,7 @@ jest.mock('../src/components/Icon/IllustrationLoader.ts', () => ({ })); jest.mock( - '@components/FlatList/InvertedFlatList/RenderTaskQueue', + '@components/FlatList/RenderTaskQueue', () => class SyncRenderTaskQueue { private handler: (info: unknown) => void = () => {}; diff --git a/src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx b/src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx deleted file mode 100644 index 8f1640a2305d..000000000000 --- a/src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; -import {View} from 'react-native'; - -type CellRendererComponentProps = ViewProps & { - index: number; - style?: StyleProp; -}; - -function CellRendererComponent(props: CellRendererComponentProps) { - return ( - - ); -} - -export default CellRendererComponent; diff --git a/src/components/FlatList/InvertedFlatList/index.tsx b/src/components/FlatList/InvertedFlatList/index.tsx deleted file mode 100644 index 87af6b05d957..000000000000 --- a/src/components/FlatList/InvertedFlatList/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import FlatList from '@components/FlatList/FlatList'; -import useFlatListScrollKey from '@components/FlatList/hooks/useFlatListScrollKey'; -import CellRendererComponent from './CellRendererComponent'; -import shouldRemoveClippedSubviews from './shouldRemoveClippedSubviews'; -import type {InvertedFlatListProps} from './types'; - -// Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 -function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { - if (item != null) { - if (typeof item === 'object' && 'key' in item) { - return item.key; - } - if (typeof item === 'object' && 'id' in item) { - return item.id; - } - } - return String(index); -} - -function InvertedFlatList({ - ref, - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor = defaultKeyExtractor, - ...restProps -}: InvertedFlatListProps) { - const {displayedData, maintainVisibleContentPosition, handleStartReached, handleRenderItem, listRef} = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - inverted: true, - onStartReached, - shouldEnableAutoScrollToTopThreshold, - renderItem, - ref, - }); - - return ( - - // eslint-disable-next-line react/jsx-props-no-spreading - {...restProps} - ref={listRef} - maintainVisibleContentPosition={maintainVisibleContentPosition} - inverted - data={displayedData} - renderItem={handleRenderItem} - keyExtractor={keyExtractor} - onStartReached={handleStartReached} - CellRendererComponent={CellRendererComponent} - removeClippedSubviews={shouldRemoveClippedSubviews} - /> - ); -} - -export default InvertedFlatList; diff --git a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts b/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts deleted file mode 100644 index 1da980d0e6cc..000000000000 --- a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * To achieve absolute positioning and handle overflows for list items, the property must be disabled - * for Android native builds. - * Source: https://reactnative.dev/docs/0.71/optimizing-flatlist-configuration#removeclippedsubviews - */ -export default false; diff --git a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts b/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts deleted file mode 100644 index f237ddf58ed4..000000000000 --- a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts +++ /dev/null @@ -1 +0,0 @@ -export default undefined; diff --git a/src/components/FlatList/InvertedFlatList/types.ts b/src/components/FlatList/InvertedFlatList/types.ts deleted file mode 100644 index a779bc247bd9..000000000000 --- a/src/components/FlatList/InvertedFlatList/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type {ListRenderItem, FlatList as RNFlatList} from 'react-native'; -import type {CustomFlatListProps} from '@components/FlatList/FlatList/types'; - -type InvertedFlatListProps = Omit, 'data' | 'renderItem' | 'initialScrollIndex'> & { - shouldEnableAutoScrollToTopThreshold?: boolean; - data: T[]; - renderItem: ListRenderItem; - initialScrollKey?: string | null; - ref?: ForwardedRef; -}; - -// eslint-disable-next-line import/prefer-default-export -export type {InvertedFlatListProps}; diff --git a/src/components/FlatList/InvertedFlatList/RenderTaskQueue.tsx b/src/components/FlatList/RenderTaskQueue.tsx similarity index 100% rename from src/components/FlatList/InvertedFlatList/RenderTaskQueue.tsx rename to src/components/FlatList/RenderTaskQueue.tsx diff --git a/src/components/FlatList/hooks/useFlatListScrollKey.ts b/src/components/FlatList/hooks/useFlatListScrollKey.ts index 85af699a5c2b..85ee67fb18a7 100644 --- a/src/components/FlatList/hooks/useFlatListScrollKey.ts +++ b/src/components/FlatList/hooks/useFlatListScrollKey.ts @@ -4,7 +4,7 @@ import type {ForwardedRef} from 'react'; import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import {View} from 'react-native'; import getInitialPaginationSize from '@components/FlatList/getInitialPaginationSize'; -import RenderTaskQueue from '@components/FlatList/InvertedFlatList/RenderTaskQueue'; +import RenderTaskQueue from '@components/FlatList/RenderTaskQueue'; import type {FlatListInnerRefType} from '@components/FlatList/types'; import type {ScrollViewProps} from '@components/ScrollView'; import usePrevious from '@hooks/usePrevious'; diff --git a/tests/unit/RenderTaskQueueTest.ts b/tests/unit/RenderTaskQueueTest.ts index 010f2ce94bc5..fd2e9f8620a4 100644 --- a/tests/unit/RenderTaskQueueTest.ts +++ b/tests/unit/RenderTaskQueueTest.ts @@ -1,6 +1,6 @@ -import RenderTaskQueue from '../../src/components/FlatList/InvertedFlatList/RenderTaskQueue'; +import RenderTaskQueue from '@components/FlatList/RenderTaskQueue'; -jest.unmock('../../src/components/FlatList/InvertedFlatList/RenderTaskQueue'); +jest.unmock('@components/FlatList/RenderTaskQueue'); describe('RenderTaskQueue', () => { beforeEach(() => { From 7a3ac266419f4556f3b07d9b149d8af7f0ae7b0e Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 9 Apr 2026 10:19:44 +0200 Subject: [PATCH 34/39] Add type comments --- src/components/FlashList/InvertedFlashList/index.tsx | 7 +++++++ src/components/FlashList/useFlashListScrollKey.ts | 7 +++++++ src/components/VideoPlayer/BaseVideoPlayer.tsx | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 2438d1695177..44444f908220 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -6,9 +6,16 @@ import FlashList from '..'; import CellRendererComponent from './CellRendererComponent'; type InvertedFlashListProps = FlashListProps & { + /** Key of the item to initially scroll to when the list first renders. */ initialScrollKey?: string | null; + + /** The array of items to render in the list. */ data: T[]; + + /** Function that extracts a unique key for each item in the list. */ keyExtractor: (item: T, index: number) => string; + + /** Ref to the underlying list instance. */ ref: FlatListRefType; }; diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index 505268dfaa96..851091566f1c 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -2,9 +2,16 @@ import type {FlashListProps} from '@shopify/flash-list'; import {useEffect, useState} from 'react'; type FlashListScrollKeyProps = { + /** The array of items to render in the list. */ data: T[]; + + /** Function that extracts a unique key for each item in the list. */ keyExtractor: (item: T, index: number) => string; + + /** Key of the item to initially scroll to when the list first renders. */ initialScrollKey: string | null | undefined; + + /** Callback invoked when the user scrolls close to the start of the list. */ onStartReached: FlashListProps['onStartReached']; }; diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 4941ba798246..b42c3477ec13 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -574,7 +574,7 @@ function BaseVideoPlayer({ if (!(videoPlayerElementParentRef.current && 'addEventListener' in videoPlayerElementParentRef.current)) { return; } - // When the video is in fullscreen, we don't want the scroll to be captured by the InvertedFlatList of report screen. + // When the video is in fullscreen, we don't want the scroll to be captured by the InvertedFlashList of report screen. // This will also allow the user to scroll the video playback speed. videoPlayerElementParentRef.current.addEventListener('wheel', stopWheelPropagation); }} From e15428e68b486d0f2ddc9894275009883ba5be41 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 9 Apr 2026 10:43:43 +0200 Subject: [PATCH 35/39] Update details.md --- ...-list+2.3.0+006+fix-inverted-mvcp-android.patch | 4 ++-- patches/@shopify/flash-list/details.md | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch index f68413dc99e0..b2eb7fed1a32 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch @@ -1,5 +1,5 @@ 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 70f856a..ed4ecfa 100644 +index 70f856a..239d95e 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -1,5 +1,5 @@ @@ -24,7 +24,7 @@ index 70f856a..ed4ecfa 100644 // console.log("diff", diff, firstVisibleItemKey.current); - if (PlatformConfig.supportsOffsetCorrection) { - // console.log("scrollBy", diff); -+ var useAndroidInvertedFallback = hasDataChanged && inverted && Platform.OS === 'android'; ++ const useAndroidInvertedFallback = hasDataChanged && inverted && Platform.OS === 'android'; + if (PlatformConfig.supportsOffsetCorrection && !useAndroidInvertedFallback) { (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff); } diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index c3e1cedd8558..45570d16761f 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -25,26 +25,26 @@ - Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. - Upstream PR/issue: TBD -- E/App issue: TBD -- PR introducing patch: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/85114 ### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) - Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. - Upstream PR/issue: TBD -- E/App issue: TBD -- PR introducing patch: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/85114 ### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) - Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. - Upstream PR/issue: TBD -- E/App issue: TBD -- PR introducing patch: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/85114 ### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch) - Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component — an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: TBD +- PR introducing patch: https://github.com/Expensify/App/pull/85114 From b35178a86b1149dfb18e8b571de40e31b0875c90 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 9 Apr 2026 10:53:57 +0200 Subject: [PATCH 36/39] Clean up scroll-end timer --- src/pages/inbox/report/ReportActionsList.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e66683b42755..438a3459cc33 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -217,7 +217,7 @@ function ReportActionsList({ const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); const topReportAction = sortedVisibleReportActions.at(-1); const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); - const isScrollEndPendingRef = useRef(false); + const scrollEndTimerRef = useRef | undefined>(undefined); const isAnonymousUser = useIsAnonymousUser(); useEffect(() => { @@ -355,17 +355,18 @@ function ReportActionsList({ onScroll?.(event); // We use a timeout to wait for the scroll to finish before resetting the flag. // onMomentumScrollEnd would be ideal but it doesn't work on web. - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline) && !isScrollEndPendingRef.current) { - isScrollEndPendingRef.current = true; - setTimeout(() => { + if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline) && !scrollEndTimerRef.current) { + scrollEndTimerRef.current = setTimeout(() => { setShouldScrollToEndAfterLayout(false); - isScrollEndPendingRef.current = false; + scrollEndTimerRef.current = undefined; }, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME); } }, hasOnceLoadedReportActions: !!reportMetadata?.hasOnceLoadedReportActions, }); + useEffect(() => () => clearTimeout(scrollEndTimerRef.current), []); + useScrollToEndOnNewMessageReceived({ sizeChangeType: 'changed', scrollOffsetRef, From d06522c80ae707cb847d6865aaf3f57f66ada6f9 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 10 Apr 2026 13:00:36 +0200 Subject: [PATCH 37/39] Update patch --- ...+2.3.0+006+fix-inverted-mvcp-android.patch | 95 ++++++++++++++++--- 1 file changed, 81 insertions(+), 14 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch index b2eb7fed1a32..814d54311362 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch @@ -1,5 +1,5 @@ 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 70f856a..239d95e 100644 +index 70f856a..52546f7 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -1,5 +1,5 @@ @@ -9,7 +9,16 @@ index 70f856a..239d95e 100644 import { adjustOffsetForRTL } from "../utils/adjustOffsetForRTL"; import { PlatformConfig } from "../../native/config/PlatformHelper"; import { WarningMessages } from "../../errors/WarningMessages"; -@@ -79,7 +79,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -25,6 +25,8 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + const isUnmounted = useUnmountFlag(); + const [_, setRenderId] = useState(0); + const pauseOffsetCorrection = useRef(false); ++ const pendingAndroidInvertedRafId = useRef(null); ++ const skipNextAndroidInvertedCorrection = useRef(false); + const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); + const { setTimeout } = useUnmountAwareTimeout(); + // Track the first visible item for maintaining scroll position +@@ -79,7 +81,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe */ const applyOffsetCorrection = useCallback(() => { var _a, _b, _c; @@ -18,7 +27,19 @@ index 70f856a..239d95e 100644 // Execute all pending callbacks from previous scroll offset updates // This ensures any scroll operations that were waiting for render are completed const callbacks = pendingScrollCallbacks.current; -@@ -115,10 +115,24 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -91,6 +93,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + currentDataLength > 0 && + recyclerViewManager.shouldMaintainVisibleContentPosition()) { + const hasDataChanged = currentDataLength !== lastDataLengthRef.current; ++ // Read and reset the skip flag so it never persists across multiple correction cycles ++ const shouldSkipAndroidInvertedCorrection = hasDataChanged && inverted && Platform.OS === 'android' && skipNextAndroidInvertedCorrection.current; ++ if (shouldSkipAndroidInvertedCorrection) { ++ skipNextAndroidInvertedCorrection.current = false; ++ } + // If we have a tracked first visible item, maintain its position + if (firstVisibleItemKey.current) { + const currentIndexOfFirstVisibleItem = (_a = recyclerViewManager +@@ -115,10 +122,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled) { // console.log("diff", diff, firstVisibleItemKey.current); @@ -29,19 +50,65 @@ index 70f856a..239d95e 100644 (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff); } + else if (useAndroidInvertedFallback) { -+ const scrollToParams = horizontal -+ ? { -+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ animated: false, ++ if (!shouldSkipAndroidInvertedCorrection) { ++ const scrollToParams = horizontal ++ ? { ++ x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ animated: false, ++ } ++ : { ++ y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ animated: false, ++ }; ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); + } -+ : { -+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ animated: false, -+ }; -+ requestAnimationFrame(() => { -+ (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); -+ }); ++ // rAF scrollTo to correct after native layout commits ++ pendingAndroidInvertedRafId.current = requestAnimationFrame(() => { ++ pendingAndroidInvertedRafId.current = null; ++ (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); ++ }); ++ } + } else { const scrollToParams = horizontal ? { +@@ -162,6 +190,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + * Handles RTL layouts and first item offset adjustments. + */ + scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }) => { ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); ++ pendingAndroidInvertedRafId.current = null; ++ } ++ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { ++ skipNextAndroidInvertedCorrection.current = true; ++ } + const { horizontal } = recyclerViewManager.props; + if (scrollViewRef.current) { + // Adjust offset for RTL layouts in horizontal mode +@@ -205,6 +240,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + * Scrolls to the end of the list. + */ + scrollToEnd: async ({ animated } = {}) => { ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); ++ pendingAndroidInvertedRafId.current = null; ++ } ++ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { ++ skipNextAndroidInvertedCorrection.current = true; ++ } + const { data } = recyclerViewManager.props; + if (data && data.length > 0) { + const lastIndex = data.length - 1; +@@ -235,6 +277,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + * Returns a Promise that resolves when the scroll is complete. + */ + scrollToIndex: ({ index, animated, viewPosition, viewOffset, }) => { ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); ++ pendingAndroidInvertedRafId.current = null; ++ } + return new Promise((resolve) => { + const { horizontal } = recyclerViewManager.props; + if (scrollViewRef.current && From 47bb2f369b8bc90ee231c5c68d1cda49bb504290 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 14 Apr 2026 09:52:38 +0200 Subject: [PATCH 38/39] Hide android scroll indicator --- .../getShowScrollIndicator/index.android.ts | 8 ++++++++ .../InvertedFlashList/getShowScrollIndicator/index.ts | 5 +++++ .../InvertedFlashList/getShowScrollIndicator/types.ts | 3 +++ src/pages/inbox/report/ReportActionsList.tsx | 3 ++- 4 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.android.ts create mode 100644 src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.ts create mode 100644 src/components/FlashList/InvertedFlashList/getShowScrollIndicator/types.ts diff --git a/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.android.ts b/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.android.ts new file mode 100644 index 000000000000..4fae55425796 --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.android.ts @@ -0,0 +1,8 @@ +import type GetShowScrollIndicator from './types'; + +// On Android, the scroll indicator appears on the left side due to a known FlashList issue with inverted lists. +// See: https://shopify.github.io/flash-list/docs/usage#inverted +// So we hide it. +const getShowScrollIndicator: GetShowScrollIndicator = () => false; + +export default getShowScrollIndicator; diff --git a/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.ts b/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.ts new file mode 100644 index 000000000000..29f175e80830 --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/index.ts @@ -0,0 +1,5 @@ +import type GetShowScrollIndicator from './types'; + +const getShowScrollIndicator: GetShowScrollIndicator = (shouldScrollToEndAfterLayout) => !shouldScrollToEndAfterLayout; + +export default getShowScrollIndicator; diff --git a/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/types.ts b/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/types.ts new file mode 100644 index 000000000000..5714c8365219 --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/getShowScrollIndicator/types.ts @@ -0,0 +1,3 @@ +type GetShowScrollIndicator = (shouldScrollToEndAfterLayout: boolean) => boolean; + +export default GetShowScrollIndicator; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 438a3459cc33..243a50a6f563 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -9,6 +9,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import Button from '@components/Button'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; +import getShowScrollIndicator from '@components/FlashList/InvertedFlashList/getShowScrollIndicator'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -882,7 +883,7 @@ function ReportActionsList({ shouldFocusToTopOnMount && styles.justifyContentEnd, shouldScrollToEndAfterLayout && StyleUtils.getHiddenChatContentStyle(), ]} - showsVerticalScrollIndicator={!shouldScrollToEndAfterLayout} + showsVerticalScrollIndicator={getShowScrollIndicator(shouldScrollToEndAfterLayout)} onEndReached={onEndReached} onEndReachedThreshold={0.75} onStartReached={onStartReached} From bf2a2cb667c3de9008e3c81348e837c12ae69856 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 14 Apr 2026 10:28:36 +0200 Subject: [PATCH 39/39] Fix keyboard overlap content --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 243a50a6f563..1566fa7bbb51 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -872,7 +872,7 @@ function ReportActionsList({ accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} ref={reportScrollManager.ref} testID="report-actions-list" - style={[styles.overscrollBehaviorContain, shouldScrollToEndAfterLayout && styles.flex0]} + style={styles.overscrollBehaviorContain} data={sortedVisibleReportActions} renderItem={renderItem} keyExtractor={keyExtractor}