Reveal a sliver of later content when deep linking to an unread/linked action#92469
Reveal a sliver of later content when deep linking to an unread/linked action#92469MelvinBot wants to merge 4 commits into
Conversation
…d action When deep linking (or jumping to the unread marker) the anchored action was pinned flush against the bottom of the inverted chat list, making it look like the newest message. After the deep-link handoff settles, nudge the list by a fixed offset so ~100px of later content peeks out underneath the anchor, hinting there is more below. No-ops when the anchor is already the newest action. Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
🤖 I created this PR from an upstream branch ( To take ownership of this branch and be able to push updates from your own fork, run: Then you can close this PR and open a new one from your fork, or just push directly to this branch if you have write access. |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
|
@MelvinBot pull main |
…Offset Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
🤖 Merged |
| if (!initialScrollOffset || hasAppliedInitialScrollOffset.current) { | ||
| return; | ||
| } | ||
| const anchorIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey); |
There was a problem hiding this comment.
Good catch — both spots computed the identical findIndex. I couldn't literally reuse the targetIndex variable: it's declared after the early returns in the render body (line 94), so it isn't in scope inside the effect's RAF closure. Instead I extracted a shared getAnchorIndex() helper and call it from both places: useFlashListScrollKey.ts:44.
It's a function rather than a precomputed const so we don't run findIndex on every render — it's only evaluated where it was before (the initial-render slice path and the post-handoff RAF). Pushed in d56afcc. Lint, typecheck, react-compiler, and FlashListTest all pass.
| ref?.current?.scrollToIndex({index: anchorIndex, viewOffset: -initialScrollOffset, animated: false}); | ||
| }); | ||
| }); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps |
There was a problem hiding this comment.
@MelvinBot is it possible to get rid of this disable comment without causing regressions?
There was a problem hiding this comment.
@/tmp/reply-3373160660.md
|
@MelvinBot your current solution doesn't work on native at all |
Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
You're right — the current approach works on web but is a no-op on native. Here's why, and the fix I'd recommend. Root cause: The post-handoff Recommended fix: Drop the imperative The tradeoff: the reveal becomes item-count based ( Why the pixel approach can't easily be made to work on native
Evidence: I can implement the slice-shift fix, but two caveats: (1) it changes the offset from |
@MelvinBot Even 1 item can cause regression in case its height is large like report preview. See:
Is there any better solution? |
|
You're right — item-granular slicing can't give a controlled sliver when the next item is tall. There is a fixed-pixel approach that works on native: use FlashList v2's built-in Recommended approach
This gives a true ~100px reveal of real newer content (not empty space, not a whole item), applied at the initial scroll position (so no flicker — matches the issue's "ideally part of the initial scroll" criterion), with no per-row height measurement (so it doesn't reintroduce the render-delay problem that closed #90375). Why this survives MVCP on native (where
|
|
@MelvinBot implement this after fully reverting your current changes. |
…sitioning Replace the data-slice + double-RAF handoff + post-handoff scrollToIndex nudge with FlashList v2's native initialScrollIndex and initialScrollIndexParams driven from the deep-link/unread anchor. A small negative viewOffset reveals a ~100px sliver of later content beneath the anchor so it doesn't look like the last message. MVCP is kept enabled while deep linking so it locks onto the offset initial scroll position. Drops the useFlashListScrollKey slice machinery entirely and inlines MVCP in InvertedFlashList. Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
🤖 Done. I fully reverted the previous approach and reimplemented per this comment. What changed (
|
| Check | Result |
|---|---|
prettier --log-level error |
✅ clean |
lint-changed |
✅ exit 0 |
typecheck (tsc) |
✅ exit 0 |
jest (FlashListTest, ReportActionsListHeaderTest, ReportActionsListPaddingViewTest) |
✅ 8 passed |
react-compiler-compliance-check — InvertedFlashList |
✅ COMPILED |
react-compiler-compliance-check — ReportActionsList |
149:0: Cannot access refs during render) — pre-existing on main; my edits start at line 301, lines 1–300 are byte-identical to main |
Needs QA
The native peek (~100px sliver) and absence of scroll flicker are web-verifiable behaviors I can't exercise here — please confirm the deep-link/unread scroll lands correctly and reveals the sliver beneath the anchor.
|
@MelvinBot the new solution causes regression. Older/Newer adjacent messages don't appear immediately like production app does.
Can you fix this? |
Root causeThe blank below the anchor is a structural limitation of FlashList v2.3.0's On the first paint, // node_modules/@shopify/flash-list/.../RecyclerViewManager.ts:391-397
this.layoutManager.recomputeLayouts(0, initialScrollIndex);
const initialItemOffset = this.layoutManager.getLayout(initialScrollIndex).y;
this.engagedIndicesTracker.scrollOffset = initialItemOffset; // viewOffset NOT included
The Why we keep playing whack-a-moleEach approach trades one regression for another because of this same limitation:
The realistic optionsOption A — Patch flash-list to bake
Option B — Keep the proven slice landing, drop the pixel reveal. Revert to the slice approach (immediate neighbors, all platforms, zero new risk) and signal "there's more below" with a non-scroll affordance instead — e.g. the existing floating new-messages counter — rather than a 100px peek. Lower risk, but it changes the UX the issue asked for. My recommendationGiven that the last three native regressions were all caught by you (I can't exercise iOS/Android here), I don't want to push a 4th blind change to a core scroll path. Option A is the only path that satisfies the issue exactly, and patching flash-list is in-pattern for this repo — but it touches a hot path and needs native QA. Do you want me to implement Option A (guarded flash-list patch + revert the app-side |


Explanation of Change
When deep linking to a report action (or jumping to the unread marker), the app scrolls the chat so the linked/oldest-unread action lands at the very bottom of the inverted list. Because the anchor was pinned flush against the bottom, it looked like the newest message in the conversation — there was no visual hint that more (newer) content existed below it.
This PR keeps the existing deep-link behaviour but, after the scroll handoff settles, nudges the list by a small fixed offset so a sliver (~100px) of later content peeks out underneath the anchored action, hinting "there is more here".
How it works:
useFlashListScrollKeyis preserved unchanged, so there is no flicker regression in the deep-link landing itself.initialScrollOffsetprop is threadedReportActionsList→InvertedFlashList→useFlashListScrollKey.ReportActionsListpasses the new constantCONST.REPORT.ACTIONS.INITIAL_LINKED_ACTION_SCROLL_OFFSET(100). The offset applies to both the linked-action deep link and the unread-marker anchor (the existinginitialScrollKey = linkedReportActionID ?? unreadMarkerReportActionID).maintainVisibleContentPositionis disabled), if an offset is set and the anchor is not already the newest action (anchorIndex > 0), we issue a single non-animatedscrollToIndex({index, viewOffset: -offset}). A negativeviewOffseton the inverted list scrolls toward newer content, revealing the offset px below the anchor. AuseRefguard ensures this runs at most once.anchorIndex <= 0), it no-ops, so the bottom is reached exactly as today.InvertedFlashListconsumer (BaseVideoPlayer) is unaffected.The offset value lives in a single
CONST, so its magnitude (and sign, if a platform needs tuning) is trivial to adjust.Fixed Issues
$ #92152
PROPOSAL:
Tests
Offline tests
Same as Tests.
QA Steps
Same as Tests. Pay particular attention to verifying the ~100px peek of later content and the absence of flicker across all platforms (web, desktop, iOS native, iOS mWeb Safari, Android native, Android mWeb Chrome), since scroll/layout timing can differ per platform.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.AI Tests
MelvinBot ran the following required-local checks for
Expensify/Appand all passed:npm run typecheck(tsc, CI merge gate) — passed, no errorsnpm run typecheck-tsgo— passed, no errorsnpx prettier --checkon all changed files — passed (one file auto-formatted)eslinton the changed files — 0 errors (only pre-existing grandfatheredeslint-seatbeltwarnings inReportActionsList.tsx, none from the added line)npm run react-compiler-compliance-check check— the two logic files (InvertedFlashList/index.tsx,useFlashListScrollKey.ts) COMPILED.ReportActionsList.tsxfails compilation, but this is pre-existing — the unmodifiedmainversion fails with the identical 9 errors, so this PR does not regress it.npm testfor the relevant suites:FlashListTest,ReportActionsListHeaderTest,ReportActionsListPaddingViewTest— passed. (No existing unit test covers the slice/RAF/layout scroll-key path; that behaviour requires cross-platform manual QA.)Could not be verified automatically (reviewer/QA must confirm): the actual ~100px peek and its direction, and the absence of flicker, on web, desktop, iOS native, iOS mWeb, Android native, Android mWeb. The single post-handoff
scrollToIndexis non-animated; if any platform shows a flicker or the offset reveals the wrong side, the magnitude/sign inCONST.REPORT.ACTIONS.INITIAL_LINKED_ACTION_SCROLL_OFFSETis the single tuning point.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari