From 2fdcd6f0fe48c8be036a538a1ad01d8b932d0dce Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 12 Jun 2026 09:13:16 +0200 Subject: [PATCH 01/20] Center linked message position --- ....3.0+013+improve-scroll-key-handling.patch | 78 +++++++++++++++++++ .../FlashList/InvertedFlashList/index.tsx | 12 +++ .../FlashList/useFlashListScrollKey.ts | 58 +++++++++++--- 3 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch new file mode 100644 index 000000000000..df577620dd9e --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch @@ -0,0 +1,78 @@ +diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +index fa786bf..586014c 100644 +--- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts ++++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +@@ -127,10 +127,12 @@ export interface FlashListProps extends Omit 0) { ++ initialItemOffset = Math.max(0, initialItemOffset - (windowSize - itemSize) * viewPosition); ++ } ++ } + this.engagedIndicesTracker.scrollOffset = initialItemOffset; + } + else { +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 18e59ce..a972c93 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +@@ -566,10 +566,22 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + }, 500); + pauseOffsetCorrection.current = true; + const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; +- const offset = horizontal +- ? recyclerViewManager.getLayout(initialScrollIndex).x + additionalOffset +- : recyclerViewManager.getLayout(initialScrollIndex).y + +- additionalOffset; ++ const initialItemLayout = recyclerViewManager.getLayout(initialScrollIndex); ++ let offset = (horizontal ? initialItemLayout.x : initialItemLayout.y) + ++ additionalOffset; ++ // Position the target item within the viewport (0 = start, 0.5 = center, 1 = end), mirroring scrollToIndex. ++ const viewPosition = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewPosition; ++ if (viewPosition !== undefined) { ++ const containerSize = horizontal ++ ? recyclerViewManager.getWindowSize().width ++ : recyclerViewManager.getWindowSize().height; ++ const itemSize = horizontal ++ ? initialItemLayout.width ++ : initialItemLayout.height; ++ if (containerSize > 0) { ++ offset = Math.max(0, offset - (containerSize - itemSize) * viewPosition); ++ } ++ } + handlerMethods.scrollToOffset({ + offset, + animated: false, diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 30ec578d6e96..d026f6be1ef4 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -29,12 +29,16 @@ function InvertedFlashList({ onStartReached: onStartReachedProp, maintainVisibleContentPosition: maintainVisibleContentPositionProp, shouldMaintainVisibleContentPosition, + initialScrollIndex: initialScrollIndexProp, + initialScrollIndexParams: initialScrollIndexParamsProp, ...restProps }: InvertedFlashListProps) { const { displayedData, onStartReached, maintainVisibleContentPosition: maintainVisibleContentPositionForScrollKey, + initialScrollIndex: initialScrollIndexForScrollKey, + initialScrollIndexParams: initialScrollIndexParamsForScrollKey, } = useFlashListScrollKey({ data, keyExtractor, @@ -50,6 +54,12 @@ function InvertedFlashList({ } : maintainVisibleContentPositionForScrollKey; + // While the deep-link handoff is active the hook owns the initial scroll target; otherwise + // fall back to the props (used e.g. to align money request reports to the top on mount). + const isScrollKeyHandoffActive = initialScrollIndexForScrollKey !== undefined; + const initialScrollIndex = isScrollKeyHandoffActive ? initialScrollIndexForScrollKey : initialScrollIndexProp; + const initialScrollIndexParams = isScrollKeyHandoffActive ? initialScrollIndexParamsForScrollKey : initialScrollIndexParamsProp; + return ( {...restProps} @@ -59,6 +69,8 @@ function InvertedFlashList({ keyExtractor={keyExtractor} CellRendererComponent={CellRendererComponent} maintainVisibleContentPosition={maintainVisibleContentPosition} + initialScrollIndex={initialScrollIndex} + initialScrollIndexParams={initialScrollIndexParams} /> ); } diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index 6dd30127bbc7..43316fd66d1a 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -1,5 +1,13 @@ import type {FlashListProps} from '@shopify/flash-list'; -import {useEffect, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +/** Minimum possible height of a single list item. Used to overestimate how many items are needed to fill half of the viewport below the target item. */ +const MIN_ITEM_HEIGHT = 36; + +/** Vertical position of the target item within the viewport (0 = start, 0.5 = center, 1 = end), see FlashList's scrollToIndex. */ +const CENTER_VIEW_POSITION = 0.5; type FlashListScrollKeyProps = { /** The array of items to render in the list. */ @@ -21,6 +29,9 @@ type FlashListScrollKeyProps = { export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, shouldMaintainVisibleContentPosition}: FlashListScrollKeyProps) { const [isInitialRender, setIsInitialRender] = useState(!!initialScrollKey); const [hasLinkingSettled, setHasLinkingSettled] = useState(!initialScrollKey); + const reportScrollManager = useReportScrollManager(); + const {windowHeight} = useWindowDimensions(); + const hasAppliedCenteringCorrection = useRef(false); // Two-frame handoff for deep-link: // RAF 1: switch from sliced data to the full array — FlashList's default MVCP pins the @@ -47,18 +58,47 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr }); }, [isInitialRender, initialScrollKey]); + // Once the sliced→full data handoff has settled, apply a final corrective scroll. The + // initialScrollIndex pass centers the target using partially estimated layouts; by now the + // items around it are measured, so scrollToIndex with viewPosition lands exactly. + useEffect(() => { + if (!hasLinkingSettled || !initialScrollKey || hasAppliedCenteringCorrection.current) { + return; + } + hasAppliedCenteringCorrection.current = true; + const targetIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey); + if (targetIndex <= 0) { + return; + } + reportScrollManager.ref?.current?.scrollToIndex({index: targetIndex, viewPosition: CENTER_VIEW_POSITION, animated: false}); + }, [hasLinkingSettled, initialScrollKey, data, keyExtractor, reportScrollManager]); + const maintainVisibleContentPosition: FlashListProps['maintainVisibleContentPosition'] = {disabled: !shouldMaintainVisibleContentPosition && hasLinkingSettled}; - if (!isInitialRender || !initialScrollKey) { - return {displayedData: data, onStartReached, maintainVisibleContentPosition}; + const targetIndex = initialScrollKey && !hasLinkingSettled ? data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey) : -1; + if (targetIndex <= 0) { + return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: undefined, initialScrollIndexParams: undefined}; } - const targetIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey); - if (targetIndex <= 0) { - return {displayedData: data, onStartReached, maintainVisibleContentPosition}; + // Keep targeting the item until linking settles: FlashList re-applies the initial scroll on + // every commit for a short window after the first layout, so the index must stay correct + // across the sliced→full data swap. + const initialScrollIndexParams = {viewPosition: CENTER_VIEW_POSITION}; + + if (!isInitialRender) { + return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: targetIndex, initialScrollIndexParams}; } - // 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), onStartReached: () => {}, maintainVisibleContentPosition}; + // On the first render, slice the data so that the target item is rendered together with enough + // newer items below it to fill the bottom half of the viewport even in the worst case (every + // item at its minimum height), allowing the first paint to show the target item centered. + const itemsBelowTarget = Math.ceil(Math.ceil(windowHeight / MIN_ITEM_HEIGHT) / 2); + const sliceStart = Math.max(0, targetIndex - itemsBelowTarget); + return { + displayedData: data.slice(sliceStart), + onStartReached: () => {}, + maintainVisibleContentPosition, + initialScrollIndex: targetIndex - sliceStart, + initialScrollIndexParams, + }; } From 937919c132c9d3881566fe99b630c4f8ea46ce2e Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 12 Jun 2026 09:25:10 +0200 Subject: [PATCH 02/20] Improve centering --- src/components/FlashList/useFlashListScrollKey.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index 43316fd66d1a..46764963b7eb 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -29,6 +29,9 @@ type FlashListScrollKeyProps = { export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, shouldMaintainVisibleContentPosition}: FlashListScrollKeyProps) { const [isInitialRender, setIsInitialRender] = useState(!!initialScrollKey); const [hasLinkingSettled, setHasLinkingSettled] = useState(!initialScrollKey); + // Whether this mount started as a deep-link. A key that appears later (e.g. marking a message + // unread) must not engage the initial-scroll machinery, which only works around the first layout. + const [isLinkingFlow] = useState(!!initialScrollKey); const reportScrollManager = useReportScrollManager(); const {windowHeight} = useWindowDimensions(); const hasAppliedCenteringCorrection = useRef(false); @@ -75,14 +78,15 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr const maintainVisibleContentPosition: FlashListProps['maintainVisibleContentPosition'] = {disabled: !shouldMaintainVisibleContentPosition && hasLinkingSettled}; - const targetIndex = initialScrollKey && !hasLinkingSettled ? data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey) : -1; + const targetIndex = isLinkingFlow && initialScrollKey ? data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey) : -1; if (targetIndex <= 0) { return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: undefined, initialScrollIndexParams: undefined}; } - // Keep targeting the item until linking settles: FlashList re-applies the initial scroll on - // every commit for a short window after the first layout, so the index must stay correct - // across the sliced→full data swap. + // Keep targeting the item for the whole linking flow: FlashList re-applies the initial scroll + // on every commit for a short window after the first layout (and ignores the prop afterwards), + // so it can re-center the target when nearby items resize (e.g. expense previews swapping from + // their loading to loaded state). The index must also stay correct across the sliced→full data swap. const initialScrollIndexParams = {viewPosition: CENTER_VIEW_POSITION}; if (!isInitialRender) { From 321246d540f3c49e49bff7a21f352701d1406149 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 12 Jun 2026 11:16:09 +0200 Subject: [PATCH 03/20] Update patch, add details --- ...y+flash-list+2.3.0+008+increase-timeout.patch | 2 +- patches/@shopify/flash-list/details.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch index a76bba73cc2d..63e1304f146b 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch @@ -7,7 +7,7 @@ index 51b6f8c..d4ca252 100644 recyclerViewManager.isInitialScrollComplete = true; pauseOffsetCorrection.current = false; - }, 100); -+ }, 500); ++ }, 1000); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; const offset = horizontal diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index b1c4343e343d..c29331fe8d6a 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -61,11 +61,12 @@ ### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch) -- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms. +- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Originally bumped to 500 ms. +- Update: Bumped further to 1000 ms for centered deep-link scrolling (see patch 013). - Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/89768 -- PR introducing patch: https://github.com/Expensify/App/pull/90218 +- PR introducing patch: https://github.com/Expensify/App/pull/90218 (original), TBD (1000 ms update) ### [@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch) @@ -141,3 +142,14 @@ - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263 - PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch) + +- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`), so a chat opened via deep link or unread marker can show the target message centered in the viewport instead of pinned to an edge. Two changes: + 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. Because this re-applies on every commit until the pause window from patch 008 closes, the target is progressively re-centered as estimated item layouts get measured and as nearby items resize. + 2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands. + `viewOffset` handling is untouched and `viewPosition` is opt-in, so existing `initialScrollIndex` consumers (e.g. the top-aligned money report flow, which uses `viewOffset`) are unaffected. Consumed by `useFlashListScrollKey` via `InvertedFlashList`'s `initialScrollKey` for deep-linked messages and the unread marker. +- Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD From 3f20d6c07a9e40941dd5432070b08a2dc89ec7a5 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 12 Jun 2026 11:26:12 +0200 Subject: [PATCH 04/20] Fix patch --- ...opify+flash-list+2.3.0+013+improve-scroll-key-handling.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch index df577620dd9e..7df165b2c874 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch @@ -50,7 +50,7 @@ index 18e59ce..a972c93 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -566,10 +566,22 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - }, 500); + }, 1000); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; - const offset = horizontal From 4709b6055f395b5810d68c25ef3e1e27267d8494 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 12 Jun 2026 12:00:22 +0200 Subject: [PATCH 05/20] Fix pagination jest test --- .../FlashList/useFlashListScrollKey.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index 46764963b7eb..2ec4b13db80d 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -35,6 +35,10 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr const reportScrollManager = useReportScrollManager(); const {windowHeight} = useWindowDimensions(); const hasAppliedCenteringCorrection = useRef(false); + // Swallows onStartReached calls induced by the corrective centering scroll, which can land the + // viewport inside the onStartReached threshold zone and would fire a spurious newer-page load. + // Scroll-triggered calls outside that brief window are unaffected. + const isSuppressingOnStartReached = useRef(false); // Two-frame handoff for deep-link: // RAF 1: switch from sliced data to the full array — FlashList's default MVCP pins the @@ -73,14 +77,28 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr if (targetIndex <= 0) { return; } + isSuppressingOnStartReached.current = true; reportScrollManager.ref?.current?.scrollToIndex({index: targetIndex, viewPosition: CENTER_VIEW_POSITION, animated: false}); + // Lift the suppression once the corrective scroll has been committed. + requestAnimationFrame(() => { + isSuppressingOnStartReached.current = false; + }); }, [hasLinkingSettled, initialScrollKey, data, keyExtractor, reportScrollManager]); const maintainVisibleContentPosition: FlashListProps['maintainVisibleContentPosition'] = {disabled: !shouldMaintainVisibleContentPosition && hasLinkingSettled}; + // Checks the suppression flag at call time, so only calls landing inside the corrective-scroll + // window are swallowed. + const onStartReachedGated: FlashListProps['onStartReached'] = () => { + if (isSuppressingOnStartReached.current) { + return; + } + onStartReached?.(); + }; + const targetIndex = isLinkingFlow && initialScrollKey ? data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey) : -1; if (targetIndex <= 0) { - return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: undefined, initialScrollIndexParams: undefined}; + return {displayedData: data, onStartReached: onStartReachedGated, maintainVisibleContentPosition, initialScrollIndex: undefined, initialScrollIndexParams: undefined}; } // Keep targeting the item for the whole linking flow: FlashList re-applies the initial scroll @@ -90,7 +108,7 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr const initialScrollIndexParams = {viewPosition: CENTER_VIEW_POSITION}; if (!isInitialRender) { - return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: targetIndex, initialScrollIndexParams}; + return {displayedData: data, onStartReached: onStartReachedGated, maintainVisibleContentPosition, initialScrollIndex: targetIndex, initialScrollIndexParams}; } // On the first render, slice the data so that the target item is rendered together with enough From 7d6789c86a6be8deb45646e857bac665c74e9bb9 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 12 Jun 2026 12:38:11 +0200 Subject: [PATCH 06/20] Try removing extra correction to fix tests --- .../FlashList/useFlashListScrollKey.ts | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index 2ec4b13db80d..16f41dcaf475 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -1,6 +1,5 @@ import type {FlashListProps} from '@shopify/flash-list'; -import {useEffect, useRef, useState} from 'react'; -import useReportScrollManager from '@hooks/useReportScrollManager'; +import {useEffect, useState} from 'react'; import useWindowDimensions from '@hooks/useWindowDimensions'; /** Minimum possible height of a single list item. Used to overestimate how many items are needed to fill half of the viewport below the target item. */ @@ -32,13 +31,7 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr // Whether this mount started as a deep-link. A key that appears later (e.g. marking a message // unread) must not engage the initial-scroll machinery, which only works around the first layout. const [isLinkingFlow] = useState(!!initialScrollKey); - const reportScrollManager = useReportScrollManager(); const {windowHeight} = useWindowDimensions(); - const hasAppliedCenteringCorrection = useRef(false); - // Swallows onStartReached calls induced by the corrective centering scroll, which can land the - // viewport inside the onStartReached threshold zone and would fire a spurious newer-page load. - // Scroll-triggered calls outside that brief window are unaffected. - const isSuppressingOnStartReached = useRef(false); // Two-frame handoff for deep-link: // RAF 1: switch from sliced data to the full array — FlashList's default MVCP pins the @@ -65,40 +58,11 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr }); }, [isInitialRender, initialScrollKey]); - // Once the sliced→full data handoff has settled, apply a final corrective scroll. The - // initialScrollIndex pass centers the target using partially estimated layouts; by now the - // items around it are measured, so scrollToIndex with viewPosition lands exactly. - useEffect(() => { - if (!hasLinkingSettled || !initialScrollKey || hasAppliedCenteringCorrection.current) { - return; - } - hasAppliedCenteringCorrection.current = true; - const targetIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey); - if (targetIndex <= 0) { - return; - } - isSuppressingOnStartReached.current = true; - reportScrollManager.ref?.current?.scrollToIndex({index: targetIndex, viewPosition: CENTER_VIEW_POSITION, animated: false}); - // Lift the suppression once the corrective scroll has been committed. - requestAnimationFrame(() => { - isSuppressingOnStartReached.current = false; - }); - }, [hasLinkingSettled, initialScrollKey, data, keyExtractor, reportScrollManager]); - const maintainVisibleContentPosition: FlashListProps['maintainVisibleContentPosition'] = {disabled: !shouldMaintainVisibleContentPosition && hasLinkingSettled}; - // Checks the suppression flag at call time, so only calls landing inside the corrective-scroll - // window are swallowed. - const onStartReachedGated: FlashListProps['onStartReached'] = () => { - if (isSuppressingOnStartReached.current) { - return; - } - onStartReached?.(); - }; - const targetIndex = isLinkingFlow && initialScrollKey ? data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey) : -1; if (targetIndex <= 0) { - return {displayedData: data, onStartReached: onStartReachedGated, maintainVisibleContentPosition, initialScrollIndex: undefined, initialScrollIndexParams: undefined}; + return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: undefined, initialScrollIndexParams: undefined}; } // Keep targeting the item for the whole linking flow: FlashList re-applies the initial scroll @@ -108,7 +72,7 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr const initialScrollIndexParams = {viewPosition: CENTER_VIEW_POSITION}; if (!isInitialRender) { - return {displayedData: data, onStartReached: onStartReachedGated, maintainVisibleContentPosition, initialScrollIndex: targetIndex, initialScrollIndexParams}; + return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: targetIndex, initialScrollIndexParams}; } // On the first render, slice the data so that the target item is rendered together with enough From 48b5c9ed8043337db7c5715c9847a2970d4e3346 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 15 Jun 2026 12:18:07 +0200 Subject: [PATCH 07/20] Remove useFlashListScrollKey at all --- .../FlashList/InvertedFlashList/index.tsx | 52 +---------- .../FlashList/useFlashListScrollKey.ts | 90 ------------------- src/pages/inbox/report/ReportActionsList.tsx | 45 +++++----- 3 files changed, 23 insertions(+), 164 deletions(-) delete mode 100644 src/components/FlashList/useFlashListScrollKey.ts diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index d026f6be1ef4..c76195749c7e 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -1,14 +1,10 @@ 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'; 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[]; @@ -17,60 +13,16 @@ type InvertedFlashListProps = FlashListProps & { /** Ref to the underlying list instance. */ ref: FlatListRefType; - - /** Whether the list should handle `maintainVisibleContentPosition` */ - shouldMaintainVisibleContentPosition?: boolean; }; -function InvertedFlashList({ - data, - keyExtractor, - initialScrollKey, - onStartReached: onStartReachedProp, - maintainVisibleContentPosition: maintainVisibleContentPositionProp, - shouldMaintainVisibleContentPosition, - initialScrollIndex: initialScrollIndexProp, - initialScrollIndexParams: initialScrollIndexParamsProp, - ...restProps -}: InvertedFlashListProps) { - const { - displayedData, - onStartReached, - maintainVisibleContentPosition: maintainVisibleContentPositionForScrollKey, - initialScrollIndex: initialScrollIndexForScrollKey, - initialScrollIndexParams: initialScrollIndexParamsForScrollKey, - } = useFlashListScrollKey({ - data, - keyExtractor, - initialScrollKey, - onStartReached: onStartReachedProp, - shouldMaintainVisibleContentPosition, - }); - - const maintainVisibleContentPosition = maintainVisibleContentPositionProp - ? { - ...maintainVisibleContentPositionForScrollKey, - ...maintainVisibleContentPositionProp, - } - : maintainVisibleContentPositionForScrollKey; - - // While the deep-link handoff is active the hook owns the initial scroll target; otherwise - // fall back to the props (used e.g. to align money request reports to the top on mount). - const isScrollKeyHandoffActive = initialScrollIndexForScrollKey !== undefined; - const initialScrollIndex = isScrollKeyHandoffActive ? initialScrollIndexForScrollKey : initialScrollIndexProp; - const initialScrollIndexParams = isScrollKeyHandoffActive ? initialScrollIndexParamsForScrollKey : initialScrollIndexParamsProp; - +function InvertedFlashList({data, keyExtractor, ...restProps}: InvertedFlashListProps) { return ( {...restProps} inverted - onStartReached={onStartReached} - data={displayedData} + data={data} keyExtractor={keyExtractor} CellRendererComponent={CellRendererComponent} - maintainVisibleContentPosition={maintainVisibleContentPosition} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} /> ); } diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts deleted file mode 100644 index 16f41dcaf475..000000000000 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type {FlashListProps} from '@shopify/flash-list'; -import {useEffect, useState} from 'react'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -/** Minimum possible height of a single list item. Used to overestimate how many items are needed to fill half of the viewport below the target item. */ -const MIN_ITEM_HEIGHT = 36; - -/** Vertical position of the target item within the viewport (0 = start, 0.5 = center, 1 = end), see FlashList's scrollToIndex. */ -const CENTER_VIEW_POSITION = 0.5; - -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']; - - /** Whether the list should handle `maintainVisibleContentPosition` */ - shouldMaintainVisibleContentPosition?: boolean; -}; - -export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, shouldMaintainVisibleContentPosition}: FlashListScrollKeyProps) { - const [isInitialRender, setIsInitialRender] = useState(!!initialScrollKey); - const [hasLinkingSettled, setHasLinkingSettled] = useState(!initialScrollKey); - // Whether this mount started as a deep-link. A key that appears later (e.g. marking a message - // unread) must not engage the initial-scroll machinery, which only works around the first layout. - const [isLinkingFlow] = useState(!!initialScrollKey); - const {windowHeight} = useWindowDimensions(); - - // Two-frame handoff for deep-link: - // RAF 1: switch from sliced data to the full array — FlashList's default MVCP pins the - // linked item through the data swap. - // RAF 2: pinning has happened, disable MVCP so it doesn't cause later jumps. - useEffect(() => { - if (!isInitialRender) { - return; - } - - // Without an anchor on this frame, we are not doing the deep-link slice handoff; clear the flag so a key that - // appears later (e.g. marking a message unread) cannot reuse the "first paint" slice path. - if (!initialScrollKey) { - // If the initial scroll key gets unset, we need to disable the initial render flag, - // otherwise the list will not render.. - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsInitialRender(false); - return; - } - - requestAnimationFrame(() => { - setIsInitialRender(false); - requestAnimationFrame(() => setHasLinkingSettled(true)); - }); - }, [isInitialRender, initialScrollKey]); - - const maintainVisibleContentPosition: FlashListProps['maintainVisibleContentPosition'] = {disabled: !shouldMaintainVisibleContentPosition && hasLinkingSettled}; - - const targetIndex = isLinkingFlow && initialScrollKey ? data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey) : -1; - if (targetIndex <= 0) { - return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: undefined, initialScrollIndexParams: undefined}; - } - - // Keep targeting the item for the whole linking flow: FlashList re-applies the initial scroll - // on every commit for a short window after the first layout (and ignores the prop afterwards), - // so it can re-center the target when nearby items resize (e.g. expense previews swapping from - // their loading to loaded state). The index must also stay correct across the sliced→full data swap. - const initialScrollIndexParams = {viewPosition: CENTER_VIEW_POSITION}; - - if (!isInitialRender) { - return {displayedData: data, onStartReached, maintainVisibleContentPosition, initialScrollIndex: targetIndex, initialScrollIndexParams}; - } - - // On the first render, slice the data so that the target item is rendered together with enough - // newer items below it to fill the bottom half of the viewport even in the worst case (every - // item at its minimum height), allowing the first paint to show the target item centered. - const itemsBelowTarget = Math.ceil(Math.ceil(windowHeight / MIN_ITEM_HEIGHT) / 2); - const sliceStart = Math.max(0, targetIndex - itemsBelowTarget); - return { - displayedData: data.slice(sliceStart), - onStartReached: () => {}, - maintainVisibleContentPosition, - initialScrollIndex: targetIndex - sliceStart, - initialScrollIndexParams, - }; -} diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 4bb59cd2d6e7..a5890f20aff1 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -527,22 +527,8 @@ function ReportActionsList({ return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); }, [parentReportAction, renderedVisibleReportActions, report]); - // Precompute a reportActionID -> index map so renderItem can resolve the real index in O(1) - // instead of scanning renderedVisibleReportActions with indexOf on every render. - const actionIndexMap = useMemo(() => { - const map = new Map(); - for (const [i, action] of renderedVisibleReportActions.entries()) { - map.set(action.reportActionID, i); - } - return map; - }, [renderedVisibleReportActions]); - const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => { - // Use the action's actual index in sortedVisibleReportActions rather than the FlashList-provided index, - // because useFlashListScrollKey may slice the data for deep-link scroll positioning, making the - // FlashList index offset from the full array and causing wrong displayAsGroup computation. - const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index; const shouldDisableContextMenuForConciergeDraft = draftReportActionID === reportAction.reportActionID; return ( @@ -555,8 +541,8 @@ function ReportActionsList({ transactionThreadReport={transactionThreadReport} linkedReportActionID={linkedReportActionID} displayAsGroup={ - !isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, safeIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && - isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, safeIndex, isOffline) + !isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, index, isOffline) } shouldHideThreadDividerLine={shouldHideThreadDividerLine} shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID} @@ -579,7 +565,6 @@ function ReportActionsList({ ); }, [ - actionIndexMap, draftReportActionID, firstVisibleReportActionID, hasPreviousMessages, @@ -700,6 +685,22 @@ function ReportActionsList({ isTrackIntentUser, }); + const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1; + let initialScrollIndex: number | undefined; + let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; + if (targetIndex > 0) { + initialScrollIndex = targetIndex; + initialScrollIndexParams = {viewPosition: 0.5}; + } else if (shouldFocusToTopOnMount) { + initialScrollIndex = renderedVisibleReportActions.length - 1; + initialScrollIndexParams = {viewOffset: windowHeight}; + } + + const maintainVisibleContentPosition = { + disabled: !shouldMaintainVisibleContentPosition, + ...(shouldAutoscrollToBottom ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : {}), + }; + return ( <> item.actionName} - shouldMaintainVisibleContentPosition={shouldMaintainVisibleContentPosition} - initialScrollIndex={shouldFocusToTopOnMount ? renderedVisibleReportActions.length - 1 : undefined} - initialScrollIndexParams={shouldFocusToTopOnMount ? {viewOffset: windowHeight} : undefined} - maintainVisibleContentPosition={ - shouldAutoscrollToBottom ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : undefined - } + initialScrollIndex={initialScrollIndex} + initialScrollIndexParams={initialScrollIndexParams} + maintainVisibleContentPosition={maintainVisibleContentPosition} onLoad={onLoad} - initialScrollKey={initialScrollKey} onContentSizeChange={() => { trackVerticalScrolling(undefined); }} From ffec63034a1520a7c9491ac1747d43b95e59e008 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 15 Jun 2026 14:31:51 +0200 Subject: [PATCH 08/20] Improve patch --- ...pify+flash-list+2.3.0+013+improve-scroll-key-handling.patch | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch index 7df165b2c874..0a8035f43979 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch @@ -21,7 +21,8 @@ index 3b69234..eb6a14e 100644 +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js @@ -296,9 +296,24 @@ export class RecyclerViewManager { // stale offset, causing the wrong items to be rendered. - this.layoutManager.recomputeLayouts(0, initialScrollIndex); +- this.layoutManager.recomputeLayouts(0, initialScrollIndex); ++ this.layoutManager.recomputeLayouts(0, this.getDataLength() - 1); const initialItemLayout = this.layoutManager.getLayout(initialScrollIndex); - const initialItemOffset = this.propsRef.horizontal + let initialItemOffset = this.propsRef.horizontal From 46971d2cebe087450e8a414dc7953530416cc1bb Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 15 Jun 2026 15:22:27 +0200 Subject: [PATCH 09/20] Simplify code --- src/components/FlashList/InvertedFlashList/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index c76195749c7e..d33b8badff34 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -15,13 +15,11 @@ type InvertedFlashListProps = FlashListProps & { ref: FlatListRefType; }; -function InvertedFlashList({data, keyExtractor, ...restProps}: InvertedFlashListProps) { +function InvertedFlashList(props: InvertedFlashListProps) { return ( - {...restProps} + {...props} inverted - data={data} - keyExtractor={keyExtractor} CellRendererComponent={CellRendererComponent} /> ); From 3a7b90a11f78cd2efbd7d745922c1bced0c0ecc6 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 16 Jun 2026 12:00:40 +0200 Subject: [PATCH 10/20] Remove patch 008 updates --- .../@shopify+flash-list+2.3.0+008+increase-timeout.patch | 2 +- patches/@shopify/flash-list/details.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch index 63e1304f146b..a76bba73cc2d 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch @@ -7,7 +7,7 @@ index 51b6f8c..d4ca252 100644 recyclerViewManager.isInitialScrollComplete = true; pauseOffsetCorrection.current = false; - }, 100); -+ }, 1000); ++ }, 500); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; const offset = horizontal diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index c29331fe8d6a..fc0604aba9d0 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -61,12 +61,11 @@ ### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch) -- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Originally bumped to 500 ms. -- Update: Bumped further to 1000 ms for centered deep-link scrolling (see patch 013). +- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms. - Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/89768 -- PR introducing patch: https://github.com/Expensify/App/pull/90218 (original), TBD (1000 ms update) +- PR introducing patch: https://github.com/Expensify/App/pull/90218 ### [@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch) From cc8aae35c687f8d80652ddb1d9f136cf80e5139f Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 16 Jun 2026 12:20:36 +0200 Subject: [PATCH 11/20] Fix patch --- ...opify+flash-list+2.3.0+013+improve-scroll-key-handling.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch index 0a8035f43979..bb4fd859eb4b 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch @@ -51,7 +51,7 @@ index 18e59ce..a972c93 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -566,10 +566,22 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - }, 1000); + }, 500); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; - const offset = horizontal From ecd19c27967fd51cadc70348206016cde00ee8a8 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 16 Jun 2026 14:58:15 +0200 Subject: [PATCH 12/20] Peek next message at bottom edge when linked message is centered --- ....3.0+013+improve-scroll-key-handling.patch | 32 ++++++++++++++++--- patches/@shopify/flash-list/details.md | 3 +- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch index bb4fd859eb4b..249ec115dfbf 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch @@ -16,10 +16,12 @@ index fa786bf..586014c 100644 /** * Used to extract a unique key for a given item at the specified index. diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js -index 3b69234..eb6a14e 100644 +index 3b69234..41bfb84 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js -@@ -296,9 +296,24 @@ export class RecyclerViewManager { +@@ -294,11 +294,26 @@ export class RecyclerViewManager { + // re-estimate unmeasured items with an updated average height, changing + // the target item's position. Reading before recompute would capture a // stale offset, causing the wrong items to be rendered. - this.layoutManager.recomputeLayouts(0, initialScrollIndex); + this.layoutManager.recomputeLayouts(0, this.getDataLength() - 1); @@ -47,10 +49,10 @@ index 3b69234..eb6a14e 100644 } else { 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 18e59ce..a972c93 100644 +index 18e59ce..b812482 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -566,10 +566,22 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -566,10 +566,44 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe }, 500); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; @@ -73,6 +75,28 @@ index 18e59ce..a972c93 100644 + if (containerSize > 0) { + offset = Math.max(0, offset - (containerSize - itemSize) * viewPosition); + } ++ } ++ // Ensure a sliver of the next item peeks at the bottom edge so it is clear there are more items ++ // underneath.If bottom item is (essentially) fully visible ++ // against the bottom edge AND there is an item underneath it, nudge the bottom edge ++ // down so PEEK_OFFSET px of the next item shows. ++ if (viewPosition !== undefined && !horizontal && recyclerViewManager.props.inverted && offset > 0) { ++ const PEEK_OFFSET = 20; ++ let bottomIndex = -1; ++ for (let i = initialScrollIndex; i >= 0; i--) { ++ if (recyclerViewManager.getLayout(i).y <= offset) { ++ bottomIndex = i; ++ break; ++ } ++ } ++ if (bottomIndex > 0) { ++ const bottomItemLayout = recyclerViewManager.getLayout(bottomIndex); ++ const hiddenPortion = offset - bottomItemLayout.y; ++ // 8px is bottom padding of every item ++ if (hiddenPortion <= 8) { ++ offset = Math.max(0, bottomItemLayout.y - PEEK_OFFSET); ++ } ++ } + } handlerMethods.scrollToOffset({ offset, diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index fc0604aba9d0..1e4e3c4c9d99 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -144,9 +144,10 @@ ### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch) -- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`), so a chat opened via deep link or unread marker can show the target message centered in the viewport instead of pinned to an edge. Two changes: +- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`), so a chat opened via deep link or unread marker can show the target message centered in the viewport instead of pinned to an edge. Three changes: 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. Because this re-applies on every commit until the pause window from patch 008 closes, the target is progressively re-centered as estimated item layouts get measured and as nearby items resize. 2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands. + 3. **Bottom peek** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged so a small sliver of that next item peeks — signaling there is more content below. `viewOffset` handling is untouched and `viewPosition` is opt-in, so existing `initialScrollIndex` consumers (e.g. the top-aligned money report flow, which uses `viewOffset`) are unaffected. Consumed by `useFlashListScrollKey` via `InvertedFlashList`'s `initialScrollKey` for deep-linked messages and the unread marker. - Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`. - Upstream PR/issue: TBD From 57cf75e5a7c0f493672b60374b5d0c270c27e728 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 17 Jun 2026 11:37:31 +0200 Subject: [PATCH 13/20] Change position to top with 40px offset. Adjust correction. --- ...+2.3.0+013+improve-scroll-key-handling.patch | 17 +++++++++-------- patches/@shopify/flash-list/details.md | 2 +- src/pages/inbox/report/ReportActionsList.tsx | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch index 249ec115dfbf..da6f17c554bb 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch @@ -49,10 +49,10 @@ index 3b69234..41bfb84 100644 } else { 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 18e59ce..b812482 100644 +index 18e59ce..17c8063 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -566,10 +566,44 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -566,10 +566,45 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe }, 500); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; @@ -76,12 +76,12 @@ index 18e59ce..b812482 100644 + offset = Math.max(0, offset - (containerSize - itemSize) * viewPosition); + } + } -+ // Ensure a sliver of the next item peeks at the bottom edge so it is clear there are more items -+ // underneath.If bottom item is (essentially) fully visible -+ // against the bottom edge AND there is an item underneath it, nudge the bottom edge -+ // down so PEEK_OFFSET px of the next item shows. ++ // Make it clear there are more items to scroll to underneath the bottom edge. ++ // If the bottom item is (essentially) fully visible against the bottom edge AND there ++ // is an item underneath it, nudge the bottom edge up so CROP_OFFSET px of the current ++ // bottom item gets cropped, signalling that more content can be scrolled into view. + if (viewPosition !== undefined && !horizontal && recyclerViewManager.props.inverted && offset > 0) { -+ const PEEK_OFFSET = 20; ++ const CROP_OFFSET = 10; + let bottomIndex = -1; + for (let i = initialScrollIndex; i >= 0; i--) { + if (recyclerViewManager.getLayout(i).y <= offset) { @@ -94,7 +94,8 @@ index 18e59ce..b812482 100644 + const hiddenPortion = offset - bottomItemLayout.y; + // 8px is bottom padding of every item + if (hiddenPortion <= 8) { -+ offset = Math.max(0, bottomItemLayout.y - PEEK_OFFSET); ++ // Crop the current bottom item rather than letting it sit flush against the edge. ++ offset = bottomItemLayout.y + CROP_OFFSET; + } + } + } diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 1e4e3c4c9d99..cee50b621011 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -147,7 +147,7 @@ - Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`), so a chat opened via deep link or unread marker can show the target message centered in the viewport instead of pinned to an edge. Three changes: 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. Because this re-applies on every commit until the pause window from patch 008 closes, the target is progressively re-centered as estimated item layouts get measured and as nearby items resize. 2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands. - 3. **Bottom peek** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged so a small sliver of that next item peeks — signaling there is more content below. + 3. **Bottom crop** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged up so the current bottom item is cropped by a few pixels — signaling there is more content below. `viewOffset` handling is untouched and `viewPosition` is opt-in, so existing `initialScrollIndex` consumers (e.g. the top-aligned money report flow, which uses `viewOffset`) are unaffected. Consumed by `useFlashListScrollKey` via `InvertedFlashList`'s `initialScrollKey` for deep-linked messages and the unread marker. - Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`. - Upstream PR/issue: TBD diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 8015875a2f4d..e9e5deeec2ca 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -466,7 +466,8 @@ function ReportActionsList({ let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; if (targetIndex > 0) { initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 0.5}; + // top position with 40px offset + initialScrollIndexParams = {viewPosition: 1, viewOffset: 40}; } else if (shouldFocusToTopOnMount) { initialScrollIndex = renderedVisibleReportActions.length - 1; initialScrollIndexParams = {viewOffset: windowHeight}; From dff285e18798ec181b00195701a70020917171fe Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 17 Jun 2026 12:09:43 +0200 Subject: [PATCH 14/20] Show loading while fetching the report to keep linked message anchor stable --- src/pages/inbox/report/ReportActionsList.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e9e5deeec2ca..699e0a3b1c1a 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -478,6 +478,12 @@ function ReportActionsList({ ...(shouldAutoscrollToBottom ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : {}), }; + // When opening a linked message, wait for the first load before rendering the list: the batch of actions that + // arrives right after the initial load shifts the list and breaks the anchor to the linked action. + if (initialScrollKey && !isOffline && !reportLoadingState?.hasOnceLoadedReportActions) { + return ; + } + return ( <> Date: Wed, 17 Jun 2026 12:27:19 +0200 Subject: [PATCH 15/20] Improve details.md --- patches/@shopify/flash-list/details.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index cee50b621011..a98ed5e13784 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -144,12 +144,12 @@ ### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch) -- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`), so a chat opened via deep link or unread marker can show the target message centered in the viewport instead of pinned to an edge. Three changes: - 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. Because this re-applies on every commit until the pause window from patch 008 closes, the target is progressively re-centered as estimated item layouts get measured and as nearby items resize. +- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`). Four changes: + 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. 2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands. 3. **Bottom crop** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged up so the current bottom item is cropped by a few pixels — signaling there is more content below. - `viewOffset` handling is untouched and `viewPosition` is opt-in, so existing `initialScrollIndex` consumers (e.g. the top-aligned money report flow, which uses `viewOffset`) are unaffected. Consumed by `useFlashListScrollKey` via `InvertedFlashList`'s `initialScrollKey` for deep-linked messages and the unread marker. + 4. **`recomputeLayouts` range** in `applyInitialScrollAdjustment` (`RecyclerViewManager.js`): the recompute that precedes reading the target offset is widened from `recomputeLayouts(0, initialScrollIndex)` to `recomputeLayouts(0, this.getDataLength() - 1)`, so every item gets a measured/re-estimated layout before the positioning. - Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`. -- Upstream PR/issue: TBD -- E/App issue: TBD -- PR introducing patch: TBD +- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2318 (for point 4) +- E/App issue: https://github.com/Expensify/App/issues/92152 +- PR introducing patch: https://github.com/Expensify/App/pull/93403 From 40a2da85cc1de3a2469e25d7091491eb61dec214 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 17 Jun 2026 13:00:51 +0200 Subject: [PATCH 16/20] Re-run checks From e29132c09a0d5ab169d7cfc251e2f58f8c6ffe7b Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 17 Jun 2026 13:22:28 +0200 Subject: [PATCH 17/20] Fix test --- tests/perf-test/ReportActionsList.perf-test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 34be32abe350..d2771df0f65e 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -96,6 +96,9 @@ beforeEach(() => { setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); signUpWithTestUser(); + + // Mark the report actions as loaded + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report.reportID}`, {hasOnceLoadedReportActions: true}); }); afterEach(() => { From 36f6a043e75754190dff56a67adc2d71f9c06e25 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 18 Jun 2026 15:44:52 +0200 Subject: [PATCH 18/20] Gate the linked-message skeleton on isLoadingInitialReportActions --- 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 699e0a3b1c1a..ee8d22778db4 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -480,7 +480,7 @@ function ReportActionsList({ // When opening a linked message, wait for the first load before rendering the list: the batch of actions that // arrives right after the initial load shifts the list and breaks the anchor to the linked action. - if (initialScrollKey && !isOffline && !reportLoadingState?.hasOnceLoadedReportActions) { + if (initialScrollKey && !isOffline && !reportLoadingState?.hasOnceLoadedReportActions && reportLoadingState?.isLoadingInitialReportActions) { return ; } From e71858bfe1f67fd1487ffc4dde0b581326a6bea2 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 18 Jun 2026 15:58:15 +0200 Subject: [PATCH 19/20] Remove not necessary anymore change --- tests/perf-test/ReportActionsList.perf-test.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index d2771df0f65e..34be32abe350 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -96,9 +96,6 @@ beforeEach(() => { setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); signUpWithTestUser(); - - // Mark the report actions as loaded - Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report.reportID}`, {hasOnceLoadedReportActions: true}); }); afterEach(() => { From 9e36d12fc431e0ff29c26f9435059da444d870fb Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 23 Jun 2026 09:04:13 +0200 Subject: [PATCH 20/20] Put offset into CONST --- src/CONST/index.ts | 1 + src/pages/inbox/report/ReportActionsList.tsx | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2a7f5bf32370..dd759a21f610 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1838,6 +1838,7 @@ const CONST = { THREAD_DISABLED: ['CREATED'], LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD: 2000, ACTION_VISIBLE_THRESHOLD: 250, + LINKED_MESSAGE_OFFSET: 40, MAX_GROUPING_TIME: 300000, }, CANCEL_PAYMENT_REASONS: { diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index ee8d22778db4..0e4fe49b6ed9 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -466,8 +466,7 @@ function ReportActionsList({ let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; if (targetIndex > 0) { initialScrollIndex = targetIndex; - // top position with 40px offset - initialScrollIndexParams = {viewPosition: 1, viewOffset: 40}; + initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { initialScrollIndex = renderedVisibleReportActions.length - 1; initialScrollIndexParams = {viewOffset: windowHeight};