Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b15b8bb
Update linked message position to top with small offset
VickyStash Jun 30, 2026
651c9e4
Fix linked-message scroll jump
VickyStash Jul 1, 2026
d1a5a67
Clean up
VickyStash Jul 1, 2026
690700d
Re-organize changes to follow new decomposition
VickyStash Jul 1, 2026
8ce6bc5
Merge branch 'main' into VickyStash/feature/92152-open-linked-message…
VickyStash Jul 1, 2026
3403724
Debounce pill visibility tracking during initial linked-message posit…
VickyStash Jul 1, 2026
1431e3a
Spell fix
VickyStash Jul 1, 2026
6233ac7
Skip pill tracking during initial positioning to prevent flickering
VickyStash Jul 1, 2026
6849002
Merge branch 'main' into VickyStash/feature/92152-open-linked-message…
VickyStash Jul 2, 2026
c666fcf
Re-run checks
VickyStash Jul 2, 2026
897bfe9
Merge branch 'main' into VickyStash/feature/92152-open-linked-message…
VickyStash Jul 2, 2026
c25429f
Merge branch 'main' into VickyStash/feature/92152-open-linked-message…
VickyStash Jul 3, 2026
9e4a9f8
Merge branch 'main' into VickyStash/feature/92152-open-linked-message…
VickyStash Jul 6, 2026
4ae29e2
Remove custom drawDistance, use default
VickyStash Jul 6, 2026
5679259
Fix test
VickyStash Jul 6, 2026
65a21ea
Revert "Fix test"
VickyStash Jul 6, 2026
77fb08d
Fix test
VickyStash Jul 6, 2026
26d62a2
Merge branch 'main' into VickyStash/feature/92152-open-linked-message…
VickyStash Jul 7, 2026
59576ea
Fix duplicate maintainVisibleContentPosition declaration after main m…
VickyStash Jul 7, 2026
9d4666b
Update tests
VickyStash Jul 7, 2026
5e25de7
Return back drawDistance
VickyStash Jul 7, 2026
7908df0
Fix jumps/flicker on native when open linked message
VickyStash Jul 7, 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,166 @@
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..a9474c1 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);

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 Avoid recomputing every row for initial scrolls

Changing this to this.getDataLength() - 1 makes every FlashList with initialScrollIndex recompute layouts for the entire dataset during mount, not only report links. I checked existing callers such as src/components/LHNOptionsList/LHNOptionsList.tsx, where web restores the saved LHN scroll with initialScrollIndex; on accounts with large report lists this turns reopening the LHN into an O(n) layout pass before first paint even when the saved index is near the top. Limit the widened range to the linked-message/viewPosition path or to the indices needed to calculate the target.

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.

This change intentionally matches the upstream fix Shopify/flash-list#2318, you can check for more details there. Narrowing the range would also bring back the bug this fixes: stopping at initialScrollIndex leaves the layout table non-monotonic, so the binary search resolves to the wrong item.

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;
Comment on lines +35 to +36

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 Apply viewOffset to the initial anchor

This initial-render adjustment reads only viewPosition, but applyInitialScrollIndex later adds initialScrollIndexParams.viewOffset before doing the corrective scrollToOffset. ReportActionsList now passes {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}, so opening a linked/unread message first paints at one offset and then snaps 40px on the correction, reintroducing the visible jump this patch is trying to avoid. Include the same viewOffset in initialItemOffset before assigning engagedIndicesTracker.scrollOffset.

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.

I'm not sure we need this change here.

applyInitialScrollAdjustment (this code) and applyInitialScrollIndex do two different jobs:

  • applyInitialScrollAdjustment sets engagedIndicesTracker.scrollOffset, which only feeds computeVisibleIndices — i.e. it decides which cells get rendered, not the on-screen scroll position. That window is expanded by drawDistance, so a 40px viewOffset difference stays well within the buffer and renders the same cells either way.
  • applyInitialScrollIndex is what actually positions the content via scrollToOffset. It runs in onCommitLayoutEffect and it does include viewOffset. So the first painted frame is already at the correct offset; there's no "paint then snap 40px"

This asymmetry is also the pre-existing upstream design: before this patch, applyInitialScrollAdjustment had no initialScrollIndexParams handling at all, while applyInitialScrollIndex has always applied viewOffset. The anchor only needs to be "close enough" to render the right cells; the controller needs the exact offset because it sets the visible position.

+ 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 {
@@ -317,8 +332,20 @@ export class RecyclerViewManager {
this.applyInitialScrollAdjustment();
const visibleIndices = this.computeVisibleIndices();
// console.log("---------> visibleIndices", visibleIndices);
- this.hasRenderedProgressively = visibleIndices.every((index) => layoutManager.getLayout(index).isHeightMeasured &&
- layoutManager.getLayout(index).isWidthMeasured);
+ const isFullyMeasured = (index) => layoutManager.getLayout(index).isHeightMeasured &&
+ layoutManager.getLayout(index).isWidthMeasured;
+ // With an explicit initialScrollIndex, also wait for the drawDistance buffer to be measured before
+ // completing the first layout, so estimate-driven layout shifts converge before anything is on screen.
+ let targetIndices = visibleIndices;
+ if (this.propsRef.initialScrollIndex !== undefined && visibleIndices.length > 0 && visibleIndices.every(isFullyMeasured)) {
+ const windowSize = this.propsRef.horizontal ? this.getWindowSize().width : this.getWindowSize().height;
+ const viewportStart = this.engagedIndicesTracker.scrollOffset;
+ // Cover the worst-case one-sided buffer the engaged tracker can mount after
+ // first layout (totalBuffer * largeMultiplier in the scroll direction).
+ const bufferDistance = this.engagedIndicesTracker.drawDistance * 2 * this.engagedIndicesTracker.largeMultiplier;
+ targetIndices = layoutManager.getVisibleLayouts(Math.max(0, viewportStart - bufferDistance), viewportStart + windowSize + bufferDistance);
+ }
+ this.hasRenderedProgressively = targetIndices.every(isFullyMeasured);
if (this.hasRenderedProgressively) {
this.isFirstLayoutComplete = true;
}
@@ -327,9 +354,13 @@ export class RecyclerViewManager {
// If everything is measured then render stack will be in sync. The buffer items will get rendered in the next update
// triggered by the useOnLoad hook.
!this.hasRenderedProgressively &&
- this.updateRenderStack(
- // pick first n indices from visible ones based on batch size
- visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize)));
+ this.updateRenderStack(targetIndices === visibleIndices
+ ? // pick first n indices from visible ones based on batch size
+ visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize))
+ : // buffer phase: visible items are already measured, mount the whole
+ // buffer window at once. Same single-commit cost the engaged tracker
+ // would pay post-paint, just moved to where nothing is visible yet.
+ targetIndices);
}
}
getItemType(index) {
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..40bdddb 100644
--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
@@ -25,6 +25,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
const isUnmounted = useUnmountFlag();
const [_, setRenderId] = useState(0);
const pauseOffsetCorrection = useRef(false);
+ // Latest offset computed by applyInitialScrollIndex. The deferred (setTimeout) re-scroll reads this at
+ // fire-time instead of the value it closed over, so a stale timeout scheduled by an earlier commit can't
+ // snap back to an outdated offset after a newer commit.
+ const latestInitialScrollOffsetRef = useRef(0);
// True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in
// flight. Cleared exactly once on `isMomentumEnd` via
// `notifyProgrammaticScrollSettled`.
@@ -566,18 +570,55 @@ 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;
+ }
+ }
+ }
+ latestInitialScrollOffsetRef.current = offset;
handlerMethods.scrollToOffset({
offset,
animated: false,
skipFirstItemOffset: false,
});
+
setTimeout(() => {
handlerMethods.scrollToOffset({
- offset,
+ offset: latestInitialScrollOffsetRef.current,
animated: false,
skipFirstItemOffset: false,
});
14 changes: 14 additions & 0 deletions patches/@shopify/flash-list/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,17 @@
- 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`). Six 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.
5. **Deferred re-scroll reads the latest offset** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): the `setTimeout(0)` re-scroll used to close over the `offset` from its own commit. When a later commit recomputed a newer offset before that timeout fired, the stale timeout snapped the list back to the outdated offset — a visible jump. The offset is now stored in `latestInitialScrollOffsetRef` and read at fire-time, so any pending re-scroll targets the current offset instead of a stale one.
6. **Progressive render covers the drawDistance buffer** in `renderProgressively` (`RecyclerViewManager.js`): with an explicit `initialScrollIndex`, the drawDistance buffer used to mount right after first paint; its measurements re-estimated every still-unmeasured item before the target, which could collapse the content height below the applied scroll offset and make the native ScrollView clamp. Now the progressive-render phase also waits for the buffer around the viewport to be measured, so the layout converges before anything is painted. Only applies when `initialScrollIndex` is set; other lists keep stock behavior.
- 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 @@ -1775,6 +1775,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
43 changes: 2 additions & 41 deletions src/components/FlashList/InvertedFlashList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey';

import type {FlatListRefType} from '@pages/inbox/ReportScreenContext';

import type {FlashListProps} from '@shopify/flash-list';
Expand All @@ -10,9 +8,6 @@ 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 @@ -21,48 +16,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}
inverted
onStartReached={onStartReached}
data={displayedData}
keyExtractor={keyExtractor}
CellRendererComponent={CellRendererComponent}
maintainVisibleContentPosition={maintainVisibleContentPosition}
/>
);
}
Expand Down
65 changes: 0 additions & 65 deletions src/components/FlashList/useFlashListScrollKey.ts

This file was deleted.

Loading
Loading