Skip to content

[Due for payment 2026-11-09] [$175] mweb-New message indicator displays on self chat when open chat login on 3 sessions #104178

Closed

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: v9.5.10-1
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: #102247
Email or phone of affected tester (no customers): lekima20+617010@gmail.com
Issue reported by: Applause Internal Team
Bug source: Exploratory - Significant User Experience Deterioration
Device used: iPhone 16 pro/ 26.6
App Component: Chat Report View

Action Performed:

Precondition:
Log in the same account on Hybrid app, mweb Safari, mweb Chrome

  1. Open the Hybrid app
  2. Create a chat with any user
  3. Send a message
  4. Navigate to mweb Safari and Chrome reload the page for see the chat appearing in LHN (not open the chat)
  5. Back to Hybrid app, send a message to the chat
  6. Navigate to mweb Chrome
  7. Open the chat
  8. Navigate to mweb Safari
  9. Open the chat

Expected Result:

The new message indicator shouldn't display in conversation when opening the chat in mweb Chrome

Actual Result:

The new message indicator displays in conversation when opening the chat in mweb Chrome. (The new message indicator displays in conversation on both mweb Safari and Chrome on the first time testing. After that, only can repro on mweb Chrome. If continue, it can't repro on Chrome too, but can repro on Chrome when change new account, not repro on Safari when change account)

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

To be added by Applause Leads

Bug7287660_1792118557922.the_first_time_testing_new_message_indicator_display_both_mweb_chrom_and_safari.mp4
Bug7287660_1792119163064.only_occur_on_mweb_chrome.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Chandana Kiran
Bug ID: 7287660

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 16, 2026
  2. MelvinBot commented on Oct 16, 2026

    @MelvinBot
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When the same account is signed in on several devices, opening a chat on a second web session can show the "New messages" marker above a message the user sent from another device.

    What is the root cause of that problem?

    On first open, useUnreadMarker places the marker on a "pagination anchor" without checking who wrote that message.

    1. When the chat mounts, usePaginatedReportActions.ts:57 snapshots report.lastReadTime. Line 82 picks the oldest action newer than that time as the anchor. It has no actor check. On the second session, lastReadTime can be older than the user's own message, because only the sending device updates lastReadTime optimistically.
    2. The normal scan in shouldDisplayNewMarkerOnReportAction.ts:110-116 correctly skips self-authored messages when no marker exists yet, so it returns null.
    3. useUnreadMarker.ts:145-146 then falls back to the anchor when the scan returns null. The marker lands on the user's own message.
    4. Line 148-149 stores it as prevUnreadMarkerReportActionID. On the next render, the self-authored branch at shouldDisplayNewMarkerOnReportAction.ts:113-114 keeps it, because a marker "already exists."

    This only triggers when the session's local lastReadTime is stale at mount time and hasOnceLoadedReportActions is still false. That timing dependence explains why it reproduces on some browsers and accounts but not others.

    What changes do you think we should make in order to solve the problem?

    In useUnreadMarker.ts (lines 121-126), only use the pagination anchor as the marker when the anchored action was not written by the current user. Use the same actor rule as the scan (childLastActorAccountID for report previews, otherwise actorAccountID). The scan then decides, and it already skips the user's own messages. Unread messages from other users after the user's own message still get the marker. Scroll positioning stays the same.

    Add a useUnreadMarkerTest case: when oldestUnreadReportActionID points to a self-authored action and hasOnceLoadedReportActions is false, unreadMarkerReportActionID should be null.

    What alternative solutions did you explore? (Optional)

    Also skip self-authored actions in the findLast at usePaginatedReportActions.ts:82, so the scroll anchor and marker agree. This is broader because it changes where the chat opens. I'd keep it out unless reviewers want both to match.

    Investigation details
    • Confidence: medium. The client-side mechanism is confirmed in code. I did not reproduce the three-session flow, and I did not verify whether the backend sends the updated lastReadTime to the user's other sessions after they comment.
    • The client already expects lastReadTime to lag behind the user's own messages on other sessions. ReportUtils.ts:10515-10520 treats the chat as read when the current user is the last actor. That is why the LHN shows the chat as read, and why nothing triggers a "mark as read" that would clear the marker.
    • unreadMarkerTime is also snapshotted at mount (useUnreadMarker.ts:82), so a correct lastReadTime arriving after the chat opens does not help.
    • The anchor fallback was added in feat: Open reports at first unread action V2 and moved into useUnreadMarker by [NoQA] decompose ReportActionsList: 4. The current self-authored branch comes from fix: report unread.

    Possibly related: Marking a chat as unread doesn't stick — client immediately re-reads it, and other clients never see the change (another multi-session lastReadTime sync issue).


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/104178) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 16, 2026
  4. j3ffffff commented on Oct 16, 2026

    @j3ffffff
  5. melvin-bot commented on Oct 16, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @hoangzinh (External)

  6. changed the title [-]mweb-New message indicator displays on self chat when open chat login on 3 sessions[/-] [+][$175] mweb-New message indicator displays on self chat when open chat login on 3 sessions[/+] on Oct 16, 2026
  7. melvin-bot commented on Oct 16, 2026

    @melvin-bot
  8. melvin-bot commented on Oct 16, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  9. camesenin commented on Oct 16, 2026

    @camesenin
  10. melvin-bot commented on Oct 16, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  11. taieddie commented on Oct 16, 2026

    @taieddie

    Contributor details
    Your Expensify account email: taieddie8@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01e51b3f9ff738cf94

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When a user is signed in across multiple sessions and sends a message from one session, opening the chat for the first time in another session mistakenly displays the unread "New" message indicator above the user's own message.

    What is the root cause of that problem?

    The issue occurs in useUnreadMarker.ts and shouldDisplayNewMarkerOnReportAction.ts:

    1. When opening a chat on a secondary session that has not yet updated its local lastReadTime, usePaginatedReportActions.ts supplies oldestUnreadReportActionID pointing to the user's own message sent from the first session. In useUnreadMarker.ts (lines 121-126), this initializes oldestUnreadReportActionMarker = [oldestUnreadReportActionID, visibleIndex].

    2. In shouldDisplayNewMarkerOnReportAction.ts (lines 110-117), the scan correctly identifies that the message is from the current user (isFromCurrentUser === true). Because no unread marker existed yet in this session (prevUnreadMarkerReportActionID === null), it intentionally returns false. getUnreadMarkerReportAction therefore returns scanned = [null, -1].

    3. In useUnreadMarker.ts (lines 145-146), because scanned[0] === null, the hook falls back to oldestUnreadReportActionMarker without checking who authored that action (scanned[0] === null ? oldestUnreadReportActionMarker : scanned). This overrides shouldDisplayNewMarkerOnReportAction's intentional suppression of self-authored messages and renders the marker on the user's own message. Line 149 then latches prevUnreadMarkerReportActionID to this self-authored message, causing subsequent renders to continue displaying the indicator.

    4. In addition, in getUnreadMarkerReportAction (shouldDisplayNewMarkerOnReportAction.ts), when evaluating an incoming reply that follows a self-authored message on fresh open, nextAction points to that older self-authored message. Inside shouldDisplayNewMarkerOnReportAction, isNextMessageUnread evaluates nextAction as timestamp-unread even when no older unread message from another user exists below it. This causes the incoming reply to be suppressed by the self-authored message, leaving both messages returning false in the scan. Also, on subsequent renders, if prevUnreadMarkerReportActionID is on an incoming reply, evaluating isFromCurrentUser without checking whether the prior marker was self-authored can allow a self-authored message to steal the indicator.

    What changes do you think we should make in order to solve the problem?

    1. In src/hooks/useUnreadMarker.ts:
    • Import isReportPreviewAction from @libs/ReportActionsUtils.
    • In lines 121-126, when deriving oldestUnreadReportActionMarker from the pagination anchor on first open (!hasOnceLoadedReportActions), check whether the anchored action was authored by the current user:
      const anchorAction = sortedVisibleReportActions.at(visibleIndex);
      const isSelfAuthored = anchorAction && (isReportPreviewAction(anchorAction) ? anchorAction.childLastActorAccountID : anchorAction.actorAccountID) === currentUserAccountID;
      if (!isSelfAuthored || manuallyMarkedUnreadReportActionID === oldestUnreadReportActionID) {
      oldestUnreadReportActionMarker = [oldestUnreadReportActionID, visibleIndex];
      }
      This ensures self-authored actions do not populate oldestUnreadReportActionMarker on fresh open unless explicitly marked as unread.
    1. In src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts:
    • In shouldDisplayNewMarkerOnReportAction (line 77), hoist if (shouldHideNewMarker(message, isOffline)) return false; directly above the isEarliestReceivedOfflineMessage branch (line 72). This ensures that any action pending deletion while online is rejected before evaluating offline arrival, guaranteeing that an action whose marker must be hidden never displays an unread marker even when nextMessage is undefined or read.
    • Inside getUnreadMarkerReportAction, skip actions whose marker should be hidden (if (shouldHideNewMarker(reportAction, isOffline)) continue;), determine whether the previous marker was self-authored or is relocating after deletion, and find the index of the oldest visible eligible unread action from another user (excluding synthetic greetings and hidden actions):
      const isActionSelfAuthored = (action: OnyxTypes.ReportAction | undefined) => !!action && currentUserAccountID === (isReportPreviewAction(action) ? action.childLastActorAccountID : action.actorAccountID);
      const prevMarkedAction = prevUnreadMarkerReportActionID ? (prevSortedVisibleReportActionsObjects[prevUnreadMarkerReportActionID] ?? visibleReportActions.find((action) => action.reportActionID === prevUnreadMarkerReportActionID)) : undefined;
      const isPrevMarkerFromCurrentUser = isActionSelfAuthored(prevMarkedAction);
      const isMarkerRelocatingAfterDeletion = !!prevUnreadMarkerReportActionID && !isPrevUnreadMarkerReportActionPresent;
      const isActionEligibleOtherUserUnread = (action: OnyxTypes.ReportAction | undefined) => !!action && action.reportActionID !== CONST.CONCIERGE_GREETING_ACTION_ID && !isActionSelfAuthored(action) && isReportActionUnread(action, unreadMarkerTime) && !shouldHideNewMarker(action, isOffline);
      const oldestOtherUserUnreadIndex = isReversed ? visibleReportActions.findIndex(isActionEligibleOtherUserUnread) : visibleReportActions.findLastIndex(isActionEligibleOtherUserUnread);
    • Inside the loop, when resolving nextAction, check whether an older unread message from another user exists below nextAction, or if a self-authored marker is active or relocating:
      const hasOlderUnreadFromOtherUser = isReversed ? oldestOtherUserUnreadIndex >= 0 && oldestOtherUserUnreadIndex < (index - 1) : oldestOtherUserUnreadIndex >= 0 && oldestOtherUserUnreadIndex > (index + 1);
      const isNextActionEligible = !!nextAction && nextAction.reportActionID !== CONST.CONCIERGE_GREETING_ACTION_ID && !shouldHideNewMarker(nextAction, isOffline);
      const shouldSuppressViaNextAction = hasOlderUnreadFromOtherUser || (isNextActionEligible && (!isActionSelfAuthored(nextAction) || isPrevMarkerFromCurrentUser || isMarkerRelocatingAfterDeletion || activeManuallyMarkedUnreadReportActionID === nextAction?.reportActionID));
      const effectiveNextAction = shouldSuppressViaNextAction ? nextAction : undefined;
    • Pass nextMessage: effectiveNextAction into shouldDisplayNewMarkerOnReportAction.

    This preserves the signature and parameter types of shouldDisplayNewMarkerOnReportAction with 100% backwards compatibility for existing unit tests, while guaranteeing:

    • In pure self-chats (the reported issue), oldestUnreadReportActionMarker remains undefined, and useUnreadMarker cleanly evaluates to scanned ([null, -1]), displaying no indicator.
    • When an incoming reply from another user follows a self-authored message on fresh open, hasOlderUnreadFromOtherUser and isPrevMarkerFromCurrentUser are false, so the self-authored message does not suppress the reply. The reply naturally receives the marker through the standard scan with live window focus and scroll threshold checks intact, and the self-authored message cannot steal it on subsequent renders.
    • In Concierge chats, synthetic greeting actions (CONST.CONCIERGE_GREETING_ACTION_ID) cannot anchor the unread marker and do not count as older unread messages from other users, preventing self-authored messages from falsely suppressing incoming replies.
    • When an earlier marker is deleted and relocates across multiple unread self-authored actions, isMarkerRelocatingAfterDeletion evaluates to true, so older self-authored actions suppress newer ones, strictly preserving oldest-unread placement.
    • In interleaved sequences (O1, S, O2), hasOlderUnreadFromOtherUser is true for O2, so S suppresses O2 even if S is pending deletion, ensuring the oldest unread message O1 receives the indicator.
    • Ineligible actions (such as messages pending deletion while online) are excluded via !shouldHideNewMarker(action, isOffline), preventing hidden actions from falsely suppressing incoming replies.
    • Manual mark-as-unread via manuallyMarkedUnreadReportActionID is fully preserved.

    I will also add unit tests in tests/unit/useUnreadMarkerTest.ts and tests/unit/ReportActionsUtilsTest.ts:

    • Verifying unreadMarkerReportActionID evaluates to null on fresh open (hasOnceLoadedReportActions: false) when the only unread message is self-authored.
    • Verifying unreadMarkerReportActionID correctly anchors on a self-authored message on fresh open if manuallyMarkedUnreadReportActionID === oldestUnreadReportActionID.
    • Verifying incoming replies following an unread self-authored message correctly receive the unread marker through getUnreadMarkerReportAction across multiple renders without hopping back onto the self-authored message.
    • Verifying Concierge synthetic greetings do not count as older unread messages from other users and do not cause self-authored messages to suppress incoming replies.
    • Verifying interleaved message sequences (O1, S, O2) correctly anchor the unread marker on the oldest unread message O1, even when the middle self-authored action S is pending deletion online.
    • Verifying marker relocation after deletion across multiple unread self-authored actions correctly retains oldest-unread placement.

    What alternative solutions did you explore? (Optional)

    Earlier proposals suggest modifying ShouldDisplayNewMarkerOnReportActionParams or shouldDisplayNewMarkerOnReportAction directly, which references out-of-scope variables or breaks backwards compatibility with direct unit test callers in ReportActionsUtilsTest.ts. Other proposals unconditionally exclude self-authored nextMessage checks without verifying whether older unread messages from other users exist below it or whether an existing marker is relocating after deletion (prevUnreadMarkerReportActionID), which breaks the oldest-unread invariant across multiple unread self-authored messages or in interleaved sequences (O1, S, O2). Proposals that evaluate prevUnreadMarkerReportActionID without checking whether that prior marker is self-authored cause the indicator to hop onto the user's own message on subsequent renders when an incoming reply is present. In addition, competitors post prohibited raw diff blocks and introduce syntax errors by referencing action out of scope. Handling the contextual effectiveNextAction cleanly inside getUnreadMarkerReportAction while guarding the pagination anchor in useUnreadMarker.ts resolves the root cause with zero regressions.

  12. 33 remaining items

  13. jeffrey-graham commented on Nov 7, 2026

    @jeffrey-graham
  14. melvin-bot commented on Nov 7, 2026

    @melvin-bot
  15. melvin-bot commented on Nov 8, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  16. melvin-bot commented on Nov 8, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
    • [BugZero Assignee] I have created a GH issue for creating/updating the regression test once above steps have been agreed upon
  17. mallenexpensify commented on Nov 11, 2026

    @mallenexpensify
    Contributor

    Claude posting on behalf of Matt:

    I'll pay these tomorrow, apologies for the delay

  18. mallenexpensify commented on Nov 11, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @hoangzinh due $175 via NewDot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions