Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2fdcd6f
Center linked message position
VickyStash Jun 12, 2026
937919c
Improve centering
VickyStash Jun 12, 2026
321246d
Update patch, add details
VickyStash Jun 12, 2026
3f20d6c
Fix patch
VickyStash Jun 12, 2026
4709b60
Fix pagination jest test
VickyStash Jun 12, 2026
7d6789c
Try removing extra correction to fix tests
VickyStash Jun 12, 2026
6b9c8d8
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash Jun 15, 2026
48b5c9e
Remove useFlashListScrollKey at all
VickyStash Jun 15, 2026
0d41904
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash Jun 15, 2026
ffec630
Improve patch
VickyStash Jun 15, 2026
46971d2
Simplify code
VickyStash Jun 15, 2026
7a49d3b
Merge remote-tracking branch 'origin/main' into VickyStash/feature/op…
VickyStash Jun 16, 2026
3a7b90a
Remove patch 008 updates
VickyStash Jun 16, 2026
cc8aae3
Fix patch
VickyStash Jun 16, 2026
ecd19c2
Peek next message at bottom edge when linked message is centered
VickyStash Jun 16, 2026
8ac0cf6
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash Jun 17, 2026
57cf75e
Change position to top with 40px offset. Adjust correction.
VickyStash Jun 17, 2026
dff285e
Show loading while fetching the report to keep linked message anchor …
VickyStash Jun 17, 2026
e484da8
Improve details.md
VickyStash Jun 17, 2026
40a2da8
Re-run checks
VickyStash Jun 17, 2026
e29132c
Fix test
VickyStash Jun 17, 2026
36f6a04
Gate the linked-message skeleton on isLoadingInitialReportActions
VickyStash Jun 18, 2026
e71858b
Remove not necessary anymore change
VickyStash Jun 18, 2026
fd24728
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash Jun 19, 2026
1e16a97
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash Jun 23, 2026
9e36d12
Put offset into CONST
VickyStash Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
index fa786bf..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<TItem> extends Omit<ScrollViewProps, "maintainVi
/**
* Additional configuration for initialScrollIndex.
* Use viewOffset to apply an offset to the initial scroll position as defined by initialScrollIndex.
+ * Use viewPosition to position the item within the viewport (0 = start, 0.5 = center, 1 = end), mirroring scrollToIndex.
* Ignored if initialScrollIndex is not set.
*/
initialScrollIndexParams?: {
viewOffset?: number;
+ viewPosition?: number;
} | null | undefined;
/**
* 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..41bfb84 100644
--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js
+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js
@@ -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);
const initialItemLayout = this.layoutManager.getLayout(initialScrollIndex);
- const initialItemOffset = this.propsRef.horizontal
+ let initialItemOffset = this.propsRef.horizontal
? initialItemLayout.x
: initialItemLayout.y;
+ // Anchor the initial render window according to initialScrollIndexParams.viewPosition so the
+ // first painted frame already shows the target item at the requested position in the viewport.
+ const initialScrollIndexParams = this.propsRef.initialScrollIndexParams;
+ const viewPosition = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewPosition;
+ if (viewPosition !== undefined) {
+ const windowSize = this.propsRef.horizontal
+ ? this.getWindowSize().width
+ : this.getWindowSize().height;
+ const itemSize = this.propsRef.horizontal
+ ? initialItemLayout.width
+ : initialItemLayout.height;
+ if (windowSize > 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..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,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;
- 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);
+ }
+ }
+ // 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 CROP_OFFSET = 10;
+ 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) {
+ // Crop the current bottom item rather than letting it sit flush against the edge.
+ offset = bottomItemLayout.y + CROP_OFFSET;
+ }
+ }
+ }
handlerMethods.scrollToOffset({
offset,
animated: false,
12 changes: 12 additions & 0 deletions patches/@shopify/flash-list/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,15 @@
- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2334
- E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263
- PR introducing patch: https://github.com/Expensify/App/pull/92520

### [@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`). 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.
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: 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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
42 changes: 2 additions & 40 deletions src/components/FlashList/InvertedFlashList/index.tsx
Original file line number Diff line number Diff line change
@@ -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<T> = FlashListProps<T> & {
/** 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[];

Expand All @@ -17,48 +13,14 @@ type InvertedFlashListProps<T> = FlashListProps<T> & {

/** Ref to the underlying list instance. */
ref: FlatListRefType;

/** Whether the list should handle `maintainVisibleContentPosition` */
shouldMaintainVisibleContentPosition?: boolean;
};

function InvertedFlashList<T>({
data,
keyExtractor,
initialScrollKey,
onStartReached: onStartReachedProp,
maintainVisibleContentPosition: maintainVisibleContentPositionProp,
shouldMaintainVisibleContentPosition,
...restProps
}: InvertedFlashListProps<T>) {
const {
displayedData,
onStartReached,
maintainVisibleContentPosition: maintainVisibleContentPositionForScrollKey,
} = useFlashListScrollKey<T>({
data,
keyExtractor,
initialScrollKey,
onStartReached: onStartReachedProp,
shouldMaintainVisibleContentPosition,
});

const maintainVisibleContentPosition = maintainVisibleContentPositionProp
? {
...maintainVisibleContentPositionForScrollKey,
...maintainVisibleContentPositionProp,
}
: maintainVisibleContentPositionForScrollKey;

function InvertedFlashList<T>(props: InvertedFlashListProps<T>) {
return (
<FlashList<T>
{...restProps}
{...props}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress newer-page loads during initial anchor

When a linked/unread chat opens near the newest edge and hasNewerActions is true, this now forwards the real onStartReached on the very first FlashList mount; the removed scroll-key hook used to replace it with a no-op during the initial positioning handoff. With onStartReachedThreshold={0.75}, FlashList can immediately call loadNewerChatsAfterTransitions, prepend newer actions, and move the target away from the requested top offset before the initial anchor has settled. Consider deferring/suppressing onStartReached until the initial linked-message positioning has completed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously onStartReached was supressed cause with old initialScrollKey logic, the reportActions array was cut off initially, and user always started at bottom -> onStartReached was always triggered by mistake.
Since we have changed the approach, I think we don't need this guard anymore.

inverted
onStartReached={onStartReached}
data={displayedData}
keyExtractor={keyExtractor}
CellRendererComponent={CellRendererComponent}
maintainVisibleContentPosition={maintainVisibleContentPosition}
/>
);
}
Expand Down
64 changes: 0 additions & 64 deletions src/components/FlashList/useFlashListScrollKey.ts

This file was deleted.

51 changes: 27 additions & 24 deletions src/pages/inbox/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,22 +359,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<string, number>();
for (const [i, action] of renderedVisibleReportActions.entries()) {
map.set(action.reportActionID, i);
}
return map;
}, [renderedVisibleReportActions]);

const renderItem = useCallback(
({item: reportAction, index}: ListRenderItemInfo<OnyxTypes.ReportAction>) => {
// 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 (
Expand All @@ -388,8 +374,8 @@ function ReportActionsList({
chatReport={chatReportStable}
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}
Expand All @@ -412,7 +398,6 @@ function ReportActionsList({
);
},
[
actionIndexMap,
draftReportActionID,
firstVisibleReportActionID,
hasPreviousMessages,
Expand Down Expand Up @@ -476,6 +461,28 @@ function ReportActionsList({
isTrackIntentUser,
});

const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1;
Comment thread
JS00001 marked this conversation as resolved.
let initialScrollIndex: number | undefined;
let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined;
if (targetIndex > 0) {
initialScrollIndex = targetIndex;
initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET};
} else if (shouldFocusToTopOnMount) {
initialScrollIndex = renderedVisibleReportActions.length - 1;
initialScrollIndexParams = {viewOffset: windowHeight};
}
Comment on lines +464 to +473

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add plain english explanation of why we doing this so its easier to follow for other devs?


const maintainVisibleContentPosition = {
disabled: !shouldMaintainVisibleContentPosition,
...(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 && reportLoadingState?.isLoadingInitialReportActions) {
return <ReportActionsSkeletonView />;
}

return (
<>
<FloatingMessageCounter
Expand Down Expand Up @@ -523,14 +530,10 @@ function ReportActionsList({
contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined,
}}
getItemType={(item) => 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);
}}
Expand Down
Loading