Fix unread marker flash at chat beginning after sign-in#87518
Fix unread marker flash at chat beginning after sign-in#87518MonilBhavsar merged 6 commits intomainfrom
Conversation
Keep the skeleton visible during the gap between app load completing and the first openReport response arriving, preventing the unread marker from rendering with stale lastReadTime data. Co-authored-by: Pujan Shah <Pujan92@users.noreply.github.com>
|
I created this PR from an upstream branch since I don't have push access to your fork. To take ownership of this branch and be able to push updates, run: Then you can close this PR and open a new one from your fork. |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
|
@MelvinBot wondering why you used changes to skeleton instead of that useEffect change which you mentioned in the proposal "Update the useEffect at ReportActionsList.tsx:261-265 to also fire when reportLastReadTime transitions from empty to a real value."? |
|
Good question. During implementation I chose the skeleton approach over the The The skeleton approach prevents It's a "don't render until data is ready" fix vs. a "render then fix" approach. The skeleton guard is lower risk because it avoids the intermediate bad state entirely rather than trying to clean it up. Happy to switch to the |
|
@MelvinBot With the current approach I see the actions -> skeleton -> actions which doesn't look good. It seems to bcoz of reportActions are available but reportMetadata isn't there at that moment. So I think better to switch to useEffect approach. Screen.Recording.2026-04-10.at.18.29.07.mov |
Revert the skeleton guard change and instead fix the useEffect in ReportActionsList to also fire when reportLastReadTime transitions from empty to populated, so the marker initializes correctly after sign-in without showing an extra skeleton flash. Co-authored-by: Pujan Shah <Pujan92@users.noreply.github.com>
|
Good catch on the actions → skeleton → actions flash. Switched to the
This way the effect re-fires only on report switch or when |
|
|
||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [report.reportID]); | ||
| }, [report.reportID, reportLastReadTime === '' ? 'empty' : 'populated']); |
There was a problem hiding this comment.
@MelvinBot Instead of this, Plz make it a consistent approach in dependency with the app code
There was a problem hiding this comment.
Fixed. Reverted the reportLastReadTime dependency approach and switched to usePrevious:
- The original
useEffectis back to depending only onreport.reportID - A separate
useEffectusesprevReportLastReadTimeto detect only the empty→non-empty transition (i.e., first load after sign-in)
This avoids resetting the marker on every lastReadTime change while still fixing the original sign-in flash bug.
|
Done — I've already switched to the - }, [report.reportID]);
+ }, [report.reportID, reportLastReadTime === '' ? 'empty' : 'populated']);This fires the effect when |
|
@MelvinBot I mean, use the condition inside the useEffect block instead of checking it in dependency array which you did |
Move the reportLastReadTime empty check from the dependency array into the effect body, so it skips the update when lastReadTime is empty rather than using a derived dependency value. Co-authored-by: Pujan Shah <Pujan92@users.noreply.github.com>
|
Good call — moved the condition inside the effect body. Now |
|
@MelvinBot This is causing a regression of not showing the marker when new messages come, I think because this useEffect re-renders on every reportLastReadTime changes. we only need this to run either reportID change or only first time when reportLastReadTime has value and prevreportLastReadTime hasn't. |
…astReadTime dependency Reverts the reportLastReadTime dependency array change that caused a regression (unread marker not showing for new messages). Now uses usePrevious to detect only the empty-to-non-empty transition of reportLastReadTime, preserving the original reportID-only dependency for the main effect. Co-authored-by: Pujan Shah <Pujan92@users.noreply.github.com>
|
You're right — adding Fixed in the latest push: the original |
…tAction The single effect with reportLastReadTime in the dependency array caused unreadMarkerTime to update whenever readNewestAction marked messages as read, immediately clearing the unread indicator. Split into two effects: one for report switches (original behavior) and one that only fires when reportLastReadTime transitions from empty to a real value (sign-in case). Co-authored-by: Pujan Shah <Pujan92@users.noreply.github.com>
Resolved conflict by using unreadMarkerTime === '' check instead of prevReportLastReadTime to detect the empty-to-value transition. This avoids needing the extra usePrevious hook while achieving the same behavior. Co-authored-by: Pujan Shah <Pujan92@users.noreply.github.com>
|
Fixed the failing Root cause: Adding Fix: Split into two effects — the original |
|
The previous test failure in Evidence:
No code fix needed — the checks should go green on this re-run. |
|
@MelvinBot I think we can do it in a single useEffect block. WDYT? |
|
That's a cleaner structure, but the single-effect version would break report switching. Here's why: When the user navigates from Report A to Report B, The current two-effect split handles both cases:
To merge them into one, the condition would need to also handle the reportID change case, e.g.: const prevReportLastReadTime = usePrevious(reportLastReadTime);
const prevReportID = usePrevious(report.reportID);
useEffect(() => {
if (prevReportID !== report.reportID) {
setUnreadMarkerTime(reportLastReadTime);
return;
}
if (!!prevReportLastReadTime || !reportLastReadTime) {
return;
}
setUnreadMarkerTime(reportLastReadTime);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [report.reportID, reportLastReadTime]);But at that point the two separate effects are arguably easier to reason about. Happy to go either way — let me know your preference and I'll update the PR. |
|
@MelvinBot Don't you think setUnreadMarkerTime will be called twice in each useEffect? |
|
Good catch — there is a narrow scenario where both fire, but it's harmless. Here's the breakdown: Normal report switch (most common): Report switch when old marker was empty (rare edge): Both effects fire and both call Sign-in scenario (the actual bug): That said — the second |
Ltbltbltbltb
left a comment
There was a problem hiding this comment.
The implementation looks correct to me. The two-effect split cleanly handles both scenarios:
- Effect 1 ( dep): fires on report switch, unconditionally sets from the current
- Effect 2 ( dep): catches the sign-in race where
lastReadTimearrives after the component mounts
The unreadMarkerTime !== '' guard in Effect 2 resolves the double-call concern — if Effect 1 already set the marker (non-empty), Effect 2 exits early. No redundant state update.
All CI checks passing. Ready to mark as ready for review.
|
MelvinBot has made 6 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR. Next steps: A human should review the recent commits and resolve the conflicting CI errors manually. |
|
@MelvinBot please mark this PR as ready for review. The implementation is correct, all CI checks are passing and the two-effect approach cleanly resolves both the sign-in race condition and the double-call concern raised in the thread. |
|
@Pujan92 The implementation looks correct and all 30 code checks are passing. The only failing check is |
|
@Pujan92 — following up on your review from April 10. The implementation is ready: all 30 code checks are passing and the two-effect approach you discussed with MelvinBot is in place. Could any available C+ reviewer (@bernhardoj @Krishna2323 @aimane-chnaif) help complete the PR Reviewer Checklist and mark this PR as ready for review? The only failing check is the checklist itself. Thanks! |
|
@Ltbltbltbltb please stop spamming issues/PRs. You're violating our guideline. |
|
@MonilBhavsar Plz mark this PR as ready for review so I can proceed with checklist |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
| // loaded yet after sign-in), update the marker so it uses the fresh value | ||
| // instead of the empty string from initial mount. | ||
| useEffect(() => { | ||
| if (reportLastReadTime === '' || unreadMarkerTime !== '') { |
There was a problem hiding this comment.
❌ CONSISTENCY-5 (docs)
The eslint-disable-next-line react-hooks/exhaustive-deps suppression lacks a justification comment explaining why unreadMarkerTime is intentionally excluded from the dependency array. The block comment above explains the effect's purpose but does not explain the dependency omission.
Add a justification comment directly above or on the same line as the eslint-disable, for example:
// eslint-disable-next-line react-hooks/exhaustive-deps -- unreadMarkerTime is intentionally excluded; we only want this to fire when reportLastReadTime changes from empty to a value, not re-run when unreadMarkerTime is subsequently set
}, [reportLastReadTime]);Reviewed at: 0790afb | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
flaviadefaria
left a comment
There was a problem hiding this comment.
Fix aligns with intended product behavior.
| return; | ||
| } | ||
| setUnreadMarkerTime(reportLastReadTime); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps |
There was a problem hiding this comment.
Good catch — added the justification inline:
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| // eslint-disable-next-line react-hooks/exhaustive-deps -- unreadMarkerTime is intentionally excluded; we only want this to fire when reportLastReadTime transitions from empty to a real value, not re-run when unreadMarkerTime is subsequently set |
Pujan92
left a comment
There was a problem hiding this comment.
LGTM!
Screen.Recording.2026-04-20.at.19.11.21.mov
|
🚧 @MonilBhavsar has triggered a test Expensify/App build. You can view the workflow run here. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/MonilBhavsar in version: 9.3.62-0 🚀
Bundle Size Analysis (Sentry): |
Explanation of Change
After sign-in,
isLoadingAppflips tofalsebefore the firstopenReportresponse arrives. This causesReportActionsListto mount with a stalelastReadTime, making every action appear "unread" and painting the marker at the very first action. Once the server response lands, the marker disappears — causing a visible flash.This fix extends the
shouldShowSkeletonForAppLoadguard to also keep the skeleton visible whenisLoadingInitialReportActionsistrueandhasOnceLoadedReportActionsis stillfalse(i.e., the first server response for this report in the current session hasn't arrived yet). This ensuresReportActionsListonly mounts afterlastReadTimeis fresh, eliminating the flash.After the first load,
hasOnceLoadedReportActionsstaystruefor the session, so this condition doesn't affect subsequent navigations.Fixed Issues
$ #87274
PROPOSAL: #87274 (comment)
Tests
Offline tests
QA Steps
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.Screenshots/Videos
Android: Native
N/A - Logic-only change, no UI modifications
Android: mWeb Chrome
N/A - Logic-only change, no UI modifications
iOS: Native
N/A - Logic-only change, no UI modifications
iOS: mWeb Safari
N/A - Logic-only change, no UI modifications
MacOS: Chrome / Safari
N/A - Logic-only change, no UI modifications