You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$250] Inbox - Marking all as read does not clear unread chats and unread markers #101684
Version Number: 9.4.90-0 Reproducible in staging?: Yes Reproducible in production?: No If this was caught during regression testing, add the test name, ID and link from BrowserStack:#100693 Email or phone of affected tester (no customers):applausetester+bm2244a@applause.expensifail.com Issue reported by: Applause Internal Team Bug source: Pull Request QA execution Device used: android App Component: Left Hand Navigation (LHN)
Action Performed:
Preconditions:
Log in to an account that has unread messages
Go to staging.new.expensify.com
Go to Inbox page, switch to Unread tab
Long-press on Unread tab, select "Mark all as read"
Expected Result:
App should clear all unread chats and unread markers
A. Unread chats aren't cleared — regression from PR #100693
That PR rewrote isEmptyReport() to take the emptiness flag as a parameter, and in doing so changed what an absent value means:
Before (ReportUtils.ts:9695-9707) — read the derived attributes live; if the entry existed, use its isEmpty, even when false.
After (ReportUtils.ts:9689-9694) — derivedIsEmptyReport ?? generateIsEmptyReport(...). If the caller's value is undefined, recompute.
The fallback is not equivalent. generateIsEmptyReport() returns true for any report with no lastMessageText whose actions aren't in memory — the normal state on a freshly launched mobile app. The derived flag is persisted to disk, so it can hold false where a fresh recompute says true.
The same PR also changed wheremarkAllMessagesAsRead gets that data. It used to come from ReportUtils' own live Onyx.connect, independent of React. Now it's a render snapshot captured in InboxTabSelector.tsx:47 and passed down at :101.
When that snapshot is missing or unhydrated, every report fails the isUnread() filter, reportIDList stays empty, and the action returns before API.write — no optimistic write, no network call, no error. Silently nothing happens, which is exactly what the tester saw.
B. Unread markers can't be cleared by this action at all — pre-existing
Even with a successful write, markAllMessagesAsRead cannot remove the in-chat "New" line, for two independent reasons:
unreadMarkerTime is seeded once at mount (useUnreadMarker.ts:82) and only advanced by the unreadAction_* / readNewestAction_*DeviceEventEmitter events. markAllMessagesAsRead emits neither — it only merges lastReadTime.
shouldDisplayNewMarkerOnReportAction.ts:65-66 hard-pins the marker whenever manuallyMarkedUnreadReportActionID is set, ignoring lastReadTime entirely. Only openReport ever clears it; markAllMessagesAsRead never does.
This matters for this exact repro: if the tester created the unread state with "Mark as unread" (the usual way to satisfy the "account with unread messages" precondition), the marker survives even a fully successful mark-all-as-read.
What changes do you think we should make in order to solve the problem?
Make markAllMessagesAsRead read REPORT_ATTRIBUTES live via Onyx.connectWithoutView, matching what UnreadIndicatorUpdater already does, instead of accepting a render snapshot. This restores pre-PR behavior and lets the reportAttributesDerived plumbing come back out of InboxTabSelector and MarkAllMessagesAsReadHandler.
Restore the old contract in isEmptyReport so "no derived entry" is distinguishable from "entry says false". An absent caller value must never silently trigger a recompute that can disagree with the cached flag.
Clear the manual-unread state in markAllMessagesAsRead: include manuallyMarkedUnreadReportActionID: null in the optimistic merge and emit readNewestAction_<reportID> for each marked report, so mounted chats drop the "New" line.
Close the test gap. The only coverage is tests/actions/ReportTest.ts:3592, which calls markAllMessagesAsRead(undefined, undefined, undefined) — it never exercises the derived-isEmpty path. Add a test with REPORT_ATTRIBUTES populated, and one asserting the Unread tab's membership and what the action marks stay in agreement.
What alternative solutions did you explore?
Backend rejection + failureData rollback. There's no successData, and failureData restores the previous lastReadTime for every report, so a rejected MarkAllMessagesAsRead would look identical to "nothing cleared". I could not rule this out — VictoriaLogs was unavailable in this run, so there is no production confirmation of whether the request was sent. One log query settles it: if no MarkAllMessagesAsRead request exists for the tester's session, the client bailed and the diagnosis above is confirmed; if one exists and failed, it's the backend instead.
Frozen LHN snapshot. Ruled out — updateReportsToDisplayInLHN correctly rewrites the entry when unread flags clear (SidebarUtils.ts:404-421), so the optimistic merge is sufficient to clear LHN bolding and the tab badge.
Wrong or misordered arguments in the PR. Ruled out — every changed isUnread() / isEmptyReport() call site was audited and threads the correct value in the correct position.
A different staging-only PR. Ruled out at blob level, see below.
Supporting evidence
Release-window fit is exact. Comparing production 9.4.89-0 against staging 9.4.90-0:
The feature itself already ships in production (InboxTabSelector.tsx exists at 9.4.89-0), so "works in prod, fails in staging" is a real A/B, not a missing feature.
Also verified and ruled out: shape mismatch in useReportAttributes() (it correctly returns .reports); a different archived input in the derived isEmpty computation; partial derived entries missing isEmpty; stale closures in PopoverMenu (byte-identical prod↔staging).
Separate pre-existing asymmetry, worth noting but not the cause here: the Unread tab calls isUnread(report, undefined, ...) while the action passes the one-transaction thread report. Since isUnread takes max(report.lastReadTime, thread.lastReadTime), a one-transaction expense report whose thread was already read looks unread to the tab but read to the action. That's present verbatim in production, and would cause a partial failure, not a total one.
Confidence: high that PR #100693 is the cause (blob-level proof); medium on the precise runtime trigger, which needs the log query above or a device log of reportIDList.length at the call site.
Reproduction note: I attempted to verify on web, but agent-device in this environment supports neither right-click nor long-press, and right-click is the only entry point to this menu on desktop web. The repro was not reproduced first-hand.
The refactor that caused this is being applied repeatedly (this PR is "Phase 4", citing Phase 2's pattern, under refactor issue #66412), so the same hazard can recur in each phase. Proposed for Expensify/App:
Rule:CONSISTENCY-18 — Threading a cached Onyx value into a parameter must not make undefined mean "recompute"
Condition: Flag when a refactor replaces a module-level derived-Onyx read with a caller-passed parameter and the absent case falls back to a recompute (return derivedX ?? computeX(...)). The recompute is rarely equivalent to the persisted cached value. Make the parameter required, or keep the entry-exists check so an absent caller value can't silently flip the result. Separately, flag imperative one-shot actions in src/libs/actions/ that receive derived Onyx data as a React render snapshot instead of reading it live via Onyx.connectWithoutView.
Why: A wrong emptiness answer made isUnread() false for every report, so markAllMessagesAsRead early-returned with no API call and no optimistic write — a completely silent no-op.
Search pattern:return\s+\w*[Dd]erived\w*\s*\?\?\s*(generate|compute|get)\w+\( across src/libs/, plus Derived(Value)?\b appearing in a parameter list under src/libs/actions/.
The full rule file, with the ❌/✅ code inline in the app-coding-standards format, would go in App/.claude/skills/app-coding-standards/rules/. If it's worth adding, a maintainer can open it there as a draft PR.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/101684) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
Reproduced on web. The cause is not a missing isEmpty snapshot. In my run the derived value was present and correct, and a normal unread chat was marked read as expected.
What gets stuck is a one-expense report whose expense (thread) was already read. The Unread tab checks it without the thread, so it's listed as unread:
No API call and no optimistic update. The report stays in the Unread tab until it's opened.
Fix: pass the one-transaction thread to getIsUnreadReportForInboxTab, so the tab, the LHN bold state and the action use the same check. In both getReportsToDisplayInLHN and updateReportsToDisplayInLHN:
Please re-state the problem that we are trying to solve in this issue.
When using the "Mark all as read" action on the Inbox > Unread tab, certain one-expense reports remain stuck in the Unread tab with bold unread markers and do not clear until manually opened.
What is the root cause of that problem?
There is a discrepancy in how unread state is calculated between the Inbox tab filter and the MarkAllMessageAsRead action:
In src/libs/SidebarUtils.ts:987-995, getIsUnreadReportForInboxTab checks whether a report should appear in the "Unread" tab by calling isUnread with undefined for oneTransactionThreadReport:
Because oneTransactionThreadReport is undefined, for a one-expense report whose expense thread was already read, isUnread falls back to evaluating the parent report in isolation and returns true. Consequently, isUnreadReport is set to true, and the report is displayed in the "Unread" tab.
In src/libs/actions/Report/MarkAllMessageAsRead.tsx:67, markAllMessagesAsRead checks whether the report needs to be marked read with the thread report:
isUnread(report,oneTransactionThreadReport)
Because the one-transaction thread is already read, isUnread evaluates to false.
As a result, MarkAllMessageAsRead determines that the report is already read and excludes it from the reportIDList payload. No optimistic update and no API call are made. The report remains permanently trapped in the Unread tab until opened.
What changes do you think we should make in order to solve the problem?
Pass the oneTransactionThreadReport to getIsUnreadReportForInboxTab so that the Unread tab, the LHN bold indicator, and MarkAllMessageAsRead all use the identical, unified unread check.
1. In src/libs/SidebarUtils.ts:
Update getIsUnreadReportForInboxTab to accept oneTransactionThreadReport:
Assert the report does not appear in CONST.INBOX_TAB.UNREAD.
One-transaction expense report where thread is unread:
Given a one-transaction expense report with an unread oneTransactionThreadReport.
Assert getIsUnreadReportForInboxTab returns true.
Execute markAllMessagesAsRead and assert report is included and unread state clears.
What alternative solutions did you explore? (Optional)
Removing oneTransactionThreadReport from MarkAllMessageAsRead:
Rejected because MarkAllMessageAsRead's check is the correct business logic (an expense report whose single transaction thread has been read should not be treated as unread). Aligning SidebarUtils ensures consistent UI behavior across the entire app.
Please re-state the problem that we are trying to solve in this issue.
In the Inbox tab with the Unread filter selected, clicking Mark all as read leaves some chats in the Unread list and does not clear their unread status.
What is the root cause of that problem?
There is a mismatch between how SidebarUtils.ts determines whether a report is unread for the Inbox Unread tab and how markAllMessagesAsRead in src/libs/actions/Report/MarkAllMessageAsRead.tsx determines whether to mark a report as read:
In src/libs/SidebarUtils.ts (getIsUnreadReportForInboxTab, lines 1023-1026, called at lines 352 and 444), isUnread(report, undefined, reportAttributes) is called with undefined as the second parameter (oneTransactionThreadReport).
In src/libs/actions/Report/MarkAllMessageAsRead.tsx (lines 63-70), markAllMessagesAsRead resolves oneTransactionThreadReport and calls isUnread(report, oneTransactionThreadReport, reportAttributes). For a single-expense report where the user previously viewed the transaction thread (oneTransactionThreadReport.lastReadTime >= lastVisibleActionCreated) while the parent report report.lastReadTime is older, isUnread(report, oneTransactionThreadReport, ...) returns false (so markAllMessagesAsRead skips it), whereas getIsUnreadReportForInboxTab passes undefined for oneTransactionThreadReport and returns true (keeping it stuck in the Unread tab).
Furthermore, when oneTransactionThreadReport itself is unread, markAllMessagesAsRead calls readNewestAction(report.reportID) on the parent report without calling readNewestAction(oneTransactionThreadReport.reportID).
What changes do you think we should make in order to solve the problem?
In src/libs/SidebarUtils.ts, update getIsUnreadReportForInboxTab to accept oneTransactionThreadReport: OnyxEntry<Report> and pass it through to isUnread(report, oneTransactionThreadReport, reportAttributes) at both call sites (lines 352 and 444).
In src/libs/actions/Report/MarkAllMessageAsRead.tsx, when a report has a oneTransactionThreadReport that is unread (isUnread(oneTransactionThreadReport, undefined, reportAttributes)), also call readNewestAction(oneTransactionThreadReport.reportID, !!oneTransactionThreadReport.hasDraftComment).
@eVoloshchak friendly bump when you get a chance. My proposal covers both parts (the opened chat staying in the Unread list and the New line not clearing), and there's a test branch if that helps the review. Happy to answer any questions.
The bulk read action may target only rendered items or update a different read-state field from the unread list and badges; the actual handler needs inspection.
What changes do you think we should make in order to solve the problem?
Trace the Unread-tab context menu through bulk mark-read and its optimistic/server state. Target all unread report IDs and reconcile the same fields used by markers, handling failures.
What alternative solutions did you explore? (Optional)
Avoid a UI-only workaround that masks the symptom without fixing the state or lifecycle problem.
Validation: Test multiple offscreen unread chats, counts, offline sync, Android and web.
@eVoloshchak I'm available if you need a contributor for this issue. I'll wait for approval before opening a PR.
📣 @Hatem-tmi! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
The Unread list does clear for a moment, then comes back. A chat the action skips never gets that flash, so this is not the Unread-tab predicate disagreeing with the one-transaction thread.
Mark all as read sends one lastReadTime for every selected chat. That time comes from getDBTimeWithSkew. That helper only moves the clock forward when the client is behind the server. When the client is ahead, it sends the client clock unchanged. The server rejects a time in its future, the write fails, and failureData puts the old lastReadTime back. The Unread tab and the badge return together.
The in-chat New marker is a separate gap. It is local state on an open chat. Mark all as read never tells that chat it was read, so the marker can stay after the list is fixed.
What changes do you think we should make in order to solve the problem?
Stop using "now" for this command.
For the batch, send the newest lastVisibleActionCreated among the chats being marked. Those times were already stored by the server, so they are not in the future, and a chat is read once lastReadTime is at least that value. Use the same value for the optimistic update and the request.
For an open chat, also emit the same read event the single "Mark as read" action already emits, so the New marker moves.
Do not change getDBTimeWithSkew. Other writes rely on it never moving backward.
What alternative solutions did you explore?
Use getServerAnchoredDBTime instead. That still means "now". Before the skew is known it falls back to the client clock, which is the value the server is already rejecting.
Make the Unread tab and the action share one isUnread call. That only changes which chats are selected. It does not explain the list emptying and then returning.
Please re-state the problem that we are trying to solve in this issue.
On staging Android, long-pressing the Inbox Unread tab and choosing Mark all as read does nothing visible: chats stay in the Unread tab with bold/unread styling, and in-chat New markers remain. Production (9.4.89-0) does not show this; staging (9.4.90-0) does. QA tied it to PR #100693.
What is the root cause of that problem?
This is two separate defects that together produce the reported symptom.
A. Unread chats are not cleared — regression from PR #100693
That PR changed how markAllMessagesAsRead decides which reports are unread.
Before the PR, markAllMessagesAsRead called isUnread() without a derived-attributes parameter, and isEmptyReport() read REPORT_ATTRIBUTES live via a module-level Onyx.connect inside ReportUtils:
App/src/libs/actions/Report/MarkAllMessageAsRead.tsx
Lines 63 to 67 in 3caa681
That fallback is not equivalent on mobile. generateIsEmptyReport() returns true when a report has no lastMessageText and its actions are not in memory — the common state on a freshly launched Android app:
App/src/libs/ReportUtils.ts
Lines 9779 to 9790 in 2402291
function generateIsEmptyReport(report: OnyxEntry<Report>, isReportArchived: boolean | undefined): boolean {
if (!report) {
return true;
}
if (report.lastMessageText) {
return false;
}
const lastVisibleMessage = getLastVisibleMessage(report.reportID, isReportArchived);
return !lastVisibleMessage.lastMessageText;
}
The persisted derived flag can hold isEmpty: false while a fresh recompute says true. When the caller passes undefined (missing snapshot entry), isUnread() returns false, the report is skipped, and the action exits before any write:
App/src/libs/actions/Report/MarkAllMessageAsRead.tsx
Lines 67 to 79 in 2402291
App/src/pages/inbox/sidebar/InboxTabSelector.tsx
Lines 99 to 101 in 2402291
// From the To-dos tab only the chats listed there are marked read. The All and Unread tabs both cover every
// unread chat, so they mark all of them.
markAllMessagesAsRead(reportNameValuePairs, isTodoTab ? getReportIDsForTab(CONST.INBOX_TAB.TODO) : undefined, reportAttributesDerived);
Imperative one-shot actions should not depend on a render snapshot. UnreadIndicatorUpdater still reads REPORT_ATTRIBUTES live via Onyx.connectWithoutView, so the badge and the action can disagree about what's unread:
App/src/libs/UnreadIndicatorUpdater/index.ts
Lines 68 to 75 in 2402291
let reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined;
// This subscription is used to update the unread indicators count which is not linked to UI and it does not update any UI state.
Onyx.connectWithoutView({
key: ONYXKEYS.DERIVED.REPORT_ATTRIBUTES,
callback: (value) => {
reportAttributesDerived = value?.reports;
},
});
// This subscription is used to update the unread indicators count which is not linked to UI and it does not update any UI state.
Onyx.connectWithoutView({
key: ONYXKEYS.DERIVED.REPORT_ATTRIBUTES,
callback: (value)=>{
reportAttributesDerived=value?.reports;
},
});
Meanwhile the Unread tab itself correctly lists chats using the same derived isEmpty flag via SidebarUtils — so the user sees unread chats the action refuses to mark:
App/src/libs/SidebarUtils.ts
Lines 987 to 994 in 2402291
function getIsUnreadReportForInboxTab(report: Report, isReportArchived: boolean, derivedIsEmptyReport: boolean | undefined): boolean {
// The `lastActorAccountID` guard matches getOptionData: it keeps chats whose only visible message was
// deleted out of the Unread tab even though isUnread() can still be true (lastVisibleActionCreated isn't reset).
return (
isUnread(report, undefined, isReportArchived, derivedIsEmptyReport) &&
!!report.lastActorAccountID &&
getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE
);
}
Why Android / staging only: PR #100693 is the only change to MarkAllMessageAsRead.tsx between production 9.4.89-0 and staging 9.4.90-0. Android HybridApp cold-starts with fewer report actions hydrated in memory, which makes the generateIsEmptyReport fallback falsely classify real unread chats as empty. Other platforms were not checked in QA; the regression is not Android-specific in code.
B. Unread markers are not cleared — pre-existing gap
Even when markAllMessagesAsRead succeeds, it only merges lastReadTime. It never clears manual-unread state or notifies mounted report views.
unreadMarkerTime in useUnreadMarker is seeded once at mount and only advanced by unreadAction_* / readNewestAction_* events — markAllMessagesAsRead emits neither:
App/src/hooks/useUnreadMarker.ts
Lines 82 to 94 in 2402291
When manuallyMarkedUnreadReportActionID is set (typical for the "account with unread messages" precondition), the New line is hard-pinned and ignores lastReadTime:
App/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts
Lines 65 to 67 in 2402291
Only openReport clears manuallyMarkedUnreadReportActionID; readNewestAction deliberately leaves it alone:
App/src/libs/actions/Report/index.ts
Lines 3236 to 3244 in 2402291
// Deliberately leaves `manuallyMarkedUnreadReportActionID` alone so an auto-read doesn't wipe a marker the
// user created. `openReport` clears it on a return trip or a refresh.
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
lastReadTime,
},
What changes do you think we should make in order to solve the problem?
1. Make markAllMessagesAsRead read REPORT_ATTRIBUTES live via Onyx.connectWithoutView, matching UnreadIndicatorUpdater. Drop the third reportAttributesDerived parameter and remove the plumbing from InboxTabSelector and MarkAllMessagesAsReadHandler.
In src/libs/actions/Report/MarkAllMessageAsRead.tsx, add a live subscription (same pattern as UnreadIndicatorUpdater):
Drop the third parameter from the function signature:
- function markAllMessagesAsRead(reportNameValuePairs, reportIDs, reportAttributesDerived)
+ function markAllMessagesAsRead(reportNameValuePairs, reportIDs)
Read derived isEmpty from the live subscription inside the loop:
- reportAttributesDerived?.[report.reportID]?.isEmpty
+ reportAttributesDerived?.[report.reportID]?.isEmpty // read from module-level subscription, not caller snapshot
Clear manual-unread state in the optimistic merge and roll it back on failure:
This mirrors the pre-PR "use cached flag when present" semantics and protects every other Phase-4 callsite that threads derivedIsEmptyReport.
3. Clear manual-unread marker state (changes in item 1 above): merge manuallyMarkedUnreadReportActionID: null in the optimistic update and emit readNewestAction_<reportID> for each marked report so mounted chats drop the New line immediately. Roll back both fields in failureData.
Blast radius:isEmptyReport semantics change affects all unread/empty checks but restores pre-PR behavior. LHN clearing itself is already correct once lastReadTime is written — SidebarUtils.updateReportsToDisplayInLHN recomputes isUnreadReport on every report change (lines 404–418). The manual-marker clear is scoped to explicit bulk mark-as-read; single-chat auto-read paths remain unchanged.
What alternative solutions did you explore? (Optional)
Use refs in InboxTabSelector (like MarkAllMessagesAsReadHandler) — still a render snapshot; does not fix the case where the snapshot entry is missing while Onyx has isEmpty: false. Rejected.
Pass getReportIDsForTab(UNREAD) instead of scanning all reports — would mark only what the tab shows, but defect A still makes reportIDList empty when isUnread() misfires. Does not fix markers. Rejected as primary fix.
Backend-only fix — the client bails before API.write when reportIDList is empty; no request is sent. A backend change cannot help the silent no-op path.
What specific scenarios should we cover in automated tests?
markAllMessagesAsRead with REPORT_ATTRIBUTES populated where isEmpty: false but report actions are not in memory — asserts API.write is called and all targeted reports get an updated lastReadTime.
Same setup but derived entry absent — asserts the action still marks reports that have lastMessageText as read (does not false-negative via generateIsEmptyReport).
Report with manuallyMarkedUnreadReportActionID set — asserts optimistic merge clears it and readNewestAction_<reportID> is emitted.
Existing test at tests/actions/ReportTest.ts:3592 passes undefined for all three args today; update signature and add the derived-attributes cases above so this regression cannot recur.
@mvtglobally@eVoloshchak I was able to reproduce this on web (main, Oct 17). It only happens to a chat that was opened from the Unread tab, so a plain "Mark all as read" won't show it. Steps:
As user A, have at least 2 unread chats (send 2 messages from user B in different chats).
As user A, go to Inbox > Unread and click one of the unread chats to open it.
Right click (or long press) the Unread tab > Mark all as read > confirm.
The other chat is cleared, but the chat you opened stays in the Unread list and its "New" line is still there. The "No unread chats" empty state doesn't show until you switch to All and back.
Please re-state the problem that we are trying to solve in this issue.
When a user switches to the Unread tab in the Inbox, opens or views unread chats, long-presses the Unread tab, and selects Mark all as read, the opened chat remains stuck in the Unread tab list and unread markers remain visible instead of clearing.
What is the root cause of that problem?
There are three distinct causes in the current Inbox tab and markAllMessagesAsRead implementation:
stickyReport in useSidebarOrderedReports.tsx is never cleared on "Mark all as read" (src/hooks/useSidebarOrderedReports.tsx:354-378 and src/pages/inbox/sidebar/InboxTabSelector.tsx:100-113):
When a user clicks a chat while on the Unread or To-do tab, setStickyReportID(reportID) stores stickyReport = {reportID, tab: activeTab}. In computedFilteredReportIDs (src/hooks/useSidebarOrderedReports.tsx:360-377), stickyReportID is forcibly kept in filteredReportIDs even after baseFilteredReportIDs no longer includes it. When the user triggers Mark all as read in InboxTabSelector.tsx, stickyReport is never reset, so the active chat stays pinned in the Unread tab list.
markAllMessagesAsRead does not mark oneTransactionThreadReport as read and does not guard against lastVisibleActionCreated > newLastReadTime (src/libs/actions/Report/MarkAllMessageAsRead.tsx:64-77):
In MarkAllMessageAsRead.tsx:
When reportIDs is passed (for example from the To-do tab), reportsToMark only iterates over the parent report IDs in the LHN, not oneTransactionThreadReport. If isUnread(report, oneTransactionThreadReport, ...) returned true because oneTransactionThreadReport was unread, only report.reportID gets lastReadTime updated while oneTransactionThreadReport stays unread.
If report.lastVisibleActionCreated (or oneTransactionThreadReport.lastVisibleActionCreated) is slightly ahead of getDBTimeWithSkew(), setting lastReadTime: newLastReadTime still leaves lastReadTime < lastVisibleActionCreated, so isUnread continues to return true.
The open report's unread marker ("New" line) remains pinned in ReportActionsList.tsx:
In src/pages/inbox/report/ReportActionsList.tsx, currentUnreadMarkerReportActionID stays latched for the currently open report while the user views it so the "New" divider does not vanish immediately on open. Because markAllMessagesAsRead updates report.lastReadTime without resetting the open report's latched currentUnreadMarkerReportActionID, the green "New" line remains visible inside the open chat.
What changes do you think we should make in order to solve the problem?
Expose clearStickyReportID from useSidebarOrderedReportsActions (src/hooks/useSidebarOrderedReports.tsx) and call it in confirmMarkAllAsRead (src/pages/inbox/sidebar/InboxTabSelector.tsx):
In useSidebarOrderedReports.tsx, add clearStickyReportID:
In InboxTabSelector.tsx (confirmMarkAllAsRead), call clearStickyReportID() when the user confirms Mark all as read on the active tab (or when menuTab === activeTab or menuTab !== CONST.INBOX_TAB.TODO).
Update oneTransactionThreadReport and clamp lastReadTime in src/libs/actions/Report/MarkAllMessageAsRead.tsx:
consteffectiveLastReadTime=[newLastReadTime,report.lastVisibleActionCreated??'',oneTransactionThreadReport?.lastVisibleActionCreated??'',].reduce((latest,current)=>(current>latest ? current : latest),newLastReadTime);constreportKey=`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`;optimisticReports[reportKey]={lastReadTime: effectiveLastReadTime};failureReports[reportKey]={lastReadTime: report.lastReadTime??null};reportIDList.push(report.reportID);if(oneTransactionThreadReport&&isUnread(oneTransactionThreadReport,undefined,isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${oneTransactionThreadReport.reportID}`]),reportAttributesDerived?.[oneTransactionThreadReport.reportID]?.isEmpty)){constthreadKey=`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReport.reportID}`;optimisticReports[threadKey]={lastReadTime: effectiveLastReadTime};failureReports[threadKey]={lastReadTime: oneTransactionThreadReport.lastReadTime??null};reportIDList.push(oneTransactionThreadReport.reportID);}
Clear currentUnreadMarkerReportActionID in ReportActionsList.tsx when report.lastReadTime advances past report.lastVisibleActionCreated while the report is already mounted:
In src/pages/inbox/report/ReportActionsList.tsx, when report.lastReadTime updates from a previous value to >= (report.lastVisibleActionCreated ?? '') and !unreadMarkerTime (no manual "Mark as unread" timestamp), clear currentUnreadMarkerReportActionID so "Mark all as read" removes the "New" line in the open chat as well.
What alternative solutions did you explore? (Optional)
I considered clearing stickyReport inside useSidebarOrderedReports.tsx whenever inboxTabCounts[CONST.INBOX_TAB.UNREAD] drops to 0. However, if a user opens the only unread chat on the Unread tab, reading that single chat also drops the unread count to 0, and stickyReport is intentionally designed to keep that chat visible until the user switches tabs or triggers a bulk "Mark all as read". Clearing stickyReport explicitly in confirmMarkAllAsRead preserves normal single-chat sticky behavior.
Please re-state the problem that we are trying to solve in this issue.
When switching to the Unread tab in the Inbox and selecting Mark all as read from the long-press menu, the currently open chat remains visible in the Unread list and unread markers/states on single-transaction thread reports are not completely cleared.
What is the root cause of that problem?
In src/hooks/useSidebarOrderedReports.tsx, the sidebar maintains a stickyReport state ({reportID, tab}) so the currently viewed chat stays visible in the filtered list while the user is reading it. When confirmMarkAllAsRead in src/pages/inbox/sidebar/InboxTabSelector.tsx calls markAllMessagesAsRead(...), it never clears stickyReport, so the currently active chat stays pinned inside the Unread tab even after being marked read.
In src/libs/actions/Report/MarkAllMessageAsRead.tsx (lines 63-75), markAllMessagesAsRead checks isUnread(report, oneTransactionThreadReport, isReportArchived, reportAttributesDerived?.[report.reportID]?.isEmpty). When a single-transaction parent report appears unread because its oneTransactionThreadReport is unread (or when reportIDs is passed for a specific tab), optimisticReports only writes {lastReadTime: newLastReadTime} to report.reportID without also updating oneTransactionThreadReport when oneTransactionThreadReportID is resolved.
What changes do you think we should make in order to solve the problem?
In src/hooks/useSidebarOrderedReports.tsx and src/pages/inbox/sidebar/InboxTabSelector.tsx, expose a clearStickyReport action from useSidebarOrderedReportsActions() and call it inside confirmMarkAllAsRead when markAllMessagesAsRead runs so the active report does not remain stuck in the Unread tab.
In src/libs/actions/Report/MarkAllMessageAsRead.tsx, when oneTransactionThreadReport exists and is unread (isUnread(oneTransactionThreadReport, undefined, ...)), include ${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReport.reportID} in optimisticReports, failureReports, and reportIDList alongside the parent report.
What alternative solutions did you explore? (Optional)
Automatically clearing stickyReport inside a useEffect whenever isUnread flips to false was considered, but that would break the intended sticky behavior when a user simply clicks into an unread chat to read it normally. Clearing stickyReport specifically on the explicit Mark all as read action preserves normal single-chat sticky behavior.
Activity
applause-bot commentedon Sep 21, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/c329b51fb7afe7ab201e84c73e9afdd567d11ca1817f62d37f25bb746871e11f
MelvinBot commentedon Sep 21, 2026
Proposal
This is two separate defects that together produce the reported symptom. "Unread chats not cleared" is a regression from [Phase 4] - Remove module-level reportAttributesDerivedValue in isEmptyReport(). "Unread markers not cleared" is a pre-existing gap that PR QA surfaced for the first time.
What is the root cause of that problem?
A. Unread chats aren't cleared — regression from PR #100693
That PR rewrote
isEmptyReport()to take the emptiness flag as a parameter, and in doing so changed what an absent value means:ReportUtils.ts:9695-9707) — read the derived attributes live; if the entry existed, use itsisEmpty, even whenfalse.ReportUtils.ts:9689-9694) —derivedIsEmptyReport ?? generateIsEmptyReport(...). If the caller's value isundefined, recompute.The fallback is not equivalent.
generateIsEmptyReport()returnstruefor any report with nolastMessageTextwhose actions aren't in memory — the normal state on a freshly launched mobile app. The derived flag is persisted to disk, so it can holdfalsewhere a fresh recompute saystrue.The same PR also changed where
markAllMessagesAsReadgets that data. It used to come from ReportUtils' own liveOnyx.connect, independent of React. Now it's a render snapshot captured inInboxTabSelector.tsx:47and passed down at:101.When that snapshot is missing or unhydrated, every report fails the
isUnread()filter,reportIDListstays empty, and the action returns beforeAPI.write— no optimistic write, no network call, no error. Silently nothing happens, which is exactly what the tester saw.Note the same PR kept the live read in
UnreadIndicatorUpdater/index.ts:68-75. So the badge and the action can now disagree about what's unread.B. Unread markers can't be cleared by this action at all — pre-existing
Even with a successful write,
markAllMessagesAsReadcannot remove the in-chat "New" line, for two independent reasons:unreadMarkerTimeis seeded once at mount (useUnreadMarker.ts:82) and only advanced by theunreadAction_*/readNewestAction_*DeviceEventEmitterevents.markAllMessagesAsReademits neither — it only mergeslastReadTime.shouldDisplayNewMarkerOnReportAction.ts:65-66hard-pins the marker whenevermanuallyMarkedUnreadReportActionIDis set, ignoringlastReadTimeentirely. OnlyopenReportever clears it;markAllMessagesAsReadnever does.This matters for this exact repro: if the tester created the unread state with "Mark as unread" (the usual way to satisfy the "account with unread messages" precondition), the marker survives even a fully successful mark-all-as-read.
What changes do you think we should make in order to solve the problem?
markAllMessagesAsReadreadREPORT_ATTRIBUTESlive viaOnyx.connectWithoutView, matching whatUnreadIndicatorUpdateralready does, instead of accepting a render snapshot. This restores pre-PR behavior and lets thereportAttributesDerivedplumbing come back out ofInboxTabSelectorandMarkAllMessagesAsReadHandler.isEmptyReportso "no derived entry" is distinguishable from "entry saysfalse". An absent caller value must never silently trigger a recompute that can disagree with the cached flag.markAllMessagesAsRead: includemanuallyMarkedUnreadReportActionID: nullin the optimistic merge and emitreadNewestAction_<reportID>for each marked report, so mounted chats drop the "New" line.tests/actions/ReportTest.ts:3592, which callsmarkAllMessagesAsRead(undefined, undefined, undefined)— it never exercises the derived-isEmptypath. Add a test withREPORT_ATTRIBUTESpopulated, and one asserting the Unread tab's membership and what the action marks stay in agreement.What alternative solutions did you explore?
failureDatarollback. There's nosuccessData, andfailureDatarestores the previouslastReadTimefor every report, so a rejectedMarkAllMessagesAsReadwould look identical to "nothing cleared". I could not rule this out — VictoriaLogs was unavailable in this run, so there is no production confirmation of whether the request was sent. One log query settles it: if noMarkAllMessagesAsReadrequest exists for the tester's session, the client bailed and the diagnosis above is confirmed; if one exists and failed, it's the backend instead.updateReportsToDisplayInLHNcorrectly rewrites the entry when unread flags clear (SidebarUtils.ts:404-421), so the optimistic merge is sufficient to clear LHN bolding and the tab badge.isUnread()/isEmptyReport()call site was audited and threads the correct value in the correct position.Supporting evidence
Release-window fit is exact. Comparing production
9.4.89-0against staging9.4.90-0:src/libs/actions/Report/MarkAllMessageAsRead.tsx— prod blob544edd0511b9(PR pre-image) → staging blob2d766d52f1f8(PR post-image). PR [Phase 4] - Remove module-level reportAttributesDerivedValue in isEmptyReport() #100693 is the only change to this file between the two.src/hooks/useSidebarOrderedReports.tsxandsrc/libs/actions/OnyxDerived/configs/reportAttributes.ts— blobs identical prod↔staging.src/libs/ReportUtils.tsprod→staging patch, filtered toisUnread|isEmptyReport|lastReadTime, contains only PR [Phase 4] - Remove module-level reportAttributesDerivedValue in isEmptyReport() #100693's hunks.InboxTabSelector.tsxexists at9.4.89-0), so "works in prod, fails in staging" is a real A/B, not a missing feature.Also verified and ruled out: shape mismatch in
useReportAttributes()(it correctly returns.reports); a different archived input in the derivedisEmptycomputation; partial derived entries missingisEmpty; stale closures inPopoverMenu(byte-identical prod↔staging).Separate pre-existing asymmetry, worth noting but not the cause here: the Unread tab calls
isUnread(report, undefined, ...)while the action passes the one-transaction thread report. SinceisUnreadtakesmax(report.lastReadTime, thread.lastReadTime), a one-transaction expense report whose thread was already read looks unread to the tab but read to the action. That's present verbatim in production, and would cause a partial failure, not a total one.Confidence: high that PR #100693 is the cause (blob-level proof); medium on the precise runtime trigger, which needs the log query above or a device log of
reportIDList.lengthat the call site.Reproduction note: I attempted to verify on web, but
agent-devicein this environment supports neither right-click nor long-press, and right-click is the only entry point to this menu on desktop web. The repro was not reproduced first-hand.Related issues
💡 Suggested AI code review rule
The refactor that caused this is being applied repeatedly (this PR is "Phase 4", citing Phase 2's pattern, under refactor issue #66412), so the same hazard can recur in each phase. Proposed for
Expensify/App:CONSISTENCY-18— Threading a cached Onyx value into a parameter must not makeundefinedmean "recompute"return derivedX ?? computeX(...)). The recompute is rarely equivalent to the persisted cached value. Make the parameter required, or keep the entry-exists check so an absent caller value can't silently flip the result. Separately, flag imperative one-shot actions insrc/libs/actions/that receive derived Onyx data as a React render snapshot instead of reading it live viaOnyx.connectWithoutView.isUnread()false for every report, somarkAllMessagesAsReadearly-returned with no API call and no optimistic write — a completely silent no-op.return\s+\w*[Dd]erived\w*\s*\?\?\s*(generate|compute|get)\w+\(acrosssrc/libs/, plusDerived(Value)?\bappearing in a parameter list undersrc/libs/actions/.src/libs/ReportUtils.ts:9689-9694src/libs/ReportUtils.ts:9695-9707as it was before the PRThe full rule file, with the ❌/✅ code inline in the
app-coding-standardsformat, would go inApp/.claude/skills/app-coding-standards/rules/. If it's worth adding, a maintainer can open it there as a draft PR.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/101684)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run · no recording available
melvin-bot commentedon Sep 21, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @eVoloshchak (
External)melvin-bot commentedon Sep 21, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
melvin-bot commentedon Sep 24, 2026
@eVoloshchak Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
M00rish commentedon Sep 25, 2026
Reproduced on web. The cause is not a missing
isEmptysnapshot. In my run the derived value was present and correct, and a normal unread chat was marked read as expected.What gets stuck is a one-expense report whose expense (thread) was already read. The Unread tab checks it without the thread, so it's listed as unread:
App/src/libs/SidebarUtils.ts
Line 991 in e029fad
The action checks it with the thread, so it's treated as read and skipped:
App/src/libs/actions/Report/MarkAllMessageAsRead.tsx
Line 67 in e029fad
Values logged inside the action for a report the Unread tab was showing:
No API call and no optimistic update. The report stays in the Unread tab until it's opened.
Fix: pass the one-transaction thread to
getIsUnreadReportForInboxTab, so the tab, the LHN bold state and the action use the same check. In bothgetReportsToDisplayInLHNandupdateReportsToDisplayInLHN:and pass it through to
isUnreadingetIsUnreadReportForInboxTab. I tested this locally: with the same state, the Unread tab shows "No unread chats".ashusatyarthi-wq commentedon Sep 27, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When using the "Mark all as read" action on the Inbox > Unread tab, certain one-expense reports remain stuck in the Unread tab with bold unread markers and do not clear until manually opened.
What is the root cause of that problem?
There is a discrepancy in how unread state is calculated between the Inbox tab filter and the
MarkAllMessageAsReadaction:src/libs/SidebarUtils.ts:987-995,getIsUnreadReportForInboxTabchecks whether a report should appear in the "Unread" tab by callingisUnreadwithundefinedforoneTransactionThreadReport:Because
oneTransactionThreadReportisundefined, for a one-expense report whose expense thread was already read,isUnreadfalls back to evaluating the parent report in isolation and returnstrue. Consequently,isUnreadReportis set totrue, and the report is displayed in the "Unread" tab.src/libs/actions/Report/MarkAllMessageAsRead.tsx:67,markAllMessagesAsReadchecks whether the report needs to be marked read with the thread report:Because the one-transaction thread is already read,
isUnreadevaluates tofalse.MarkAllMessageAsReaddetermines that the report is already read and excludes it from thereportIDListpayload. No optimistic update and no API call are made. The report remains permanently trapped in the Unread tab until opened.What changes do you think we should make in order to solve the problem?
Pass the
oneTransactionThreadReporttogetIsUnreadReportForInboxTabso that the Unread tab, the LHN bold indicator, andMarkAllMessageAsReadall use the identical, unified unread check.1. In
src/libs/SidebarUtils.ts:Update
getIsUnreadReportForInboxTabto acceptoneTransactionThreadReport:2. Pass
oneTransactionThreadReportat call sites inSidebarUtils.ts:In both
getReportsToDisplayInLHN(line 316) andupdateReportsToDisplayInLHN(line 405):What specific scenarios should be covered in tests?
Add unit tests in
tests/unit/SidebarUtilsTest.ts:oneTransactionThreadReportwhoselastReadTime >= lastVisibleActionCreated.getIsUnreadReportForInboxTabreturnsfalse.CONST.INBOX_TAB.UNREAD.oneTransactionThreadReport.getIsUnreadReportForInboxTabreturnstrue.markAllMessagesAsReadand assert report is included and unread state clears.What alternative solutions did you explore? (Optional)
oneTransactionThreadReportfromMarkAllMessageAsRead:MarkAllMessageAsRead's check is the correct business logic (an expense report whose single transaction thread has been read should not be treated as unread). AligningSidebarUtilsensures consistent UI behavior across the entire app.melvin-bot commentedon Sep 28, 2026
@eVoloshchak Still overdue 6 days?! Let's take care of this!
melvin-bot commentedon Sep 30, 2026
@eVoloshchak 8 days overdue is a lot. Should this be a Weekly issue? If so, feel free to change it!
93 remaining items
melvin-bot commentedon Oct 19, 2026
@eVoloshchak Huh... This is 4 days overdue. Who can take care of this?
herdlevar commentedon Oct 20, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the Inbox tab with the Unread filter selected, clicking Mark all as read leaves some chats in the Unread list and does not clear their unread status.
What is the root cause of that problem?
There is a mismatch between how
SidebarUtils.tsdetermines whether a report is unread for the Inbox Unread tab and howmarkAllMessagesAsReadinsrc/libs/actions/Report/MarkAllMessageAsRead.tsxdetermines whether to mark a report as read:src/libs/SidebarUtils.ts(getIsUnreadReportForInboxTab, lines1023-1026, called at lines352and444),isUnread(report, undefined, reportAttributes)is called withundefinedas the second parameter (oneTransactionThreadReport).src/libs/actions/Report/MarkAllMessageAsRead.tsx(lines63-70),markAllMessagesAsReadresolvesoneTransactionThreadReportand callsisUnread(report, oneTransactionThreadReport, reportAttributes). For a single-expense report where the user previously viewed the transaction thread (oneTransactionThreadReport.lastReadTime >= lastVisibleActionCreated) while the parent reportreport.lastReadTimeis older,isUnread(report, oneTransactionThreadReport, ...)returnsfalse(somarkAllMessagesAsReadskips it), whereasgetIsUnreadReportForInboxTabpassesundefinedforoneTransactionThreadReportand returnstrue(keeping it stuck in the Unread tab).oneTransactionThreadReportitself is unread,markAllMessagesAsReadcallsreadNewestAction(report.reportID)on the parent report without callingreadNewestAction(oneTransactionThreadReport.reportID).What changes do you think we should make in order to solve the problem?
src/libs/SidebarUtils.ts, updategetIsUnreadReportForInboxTabto acceptoneTransactionThreadReport: OnyxEntry<Report>and pass it through toisUnread(report, oneTransactionThreadReport, reportAttributes)at both call sites (lines352and444).src/libs/actions/Report/MarkAllMessageAsRead.tsx, when a report has aoneTransactionThreadReportthat is unread (isUnread(oneTransactionThreadReport, undefined, reportAttributes)), also callreadNewestAction(oneTransactionThreadReport.reportID, !!oneTransactionThreadReport.hasDraftComment).stacklancer commentedon Oct 20, 2026
@eVoloshchak friendly bump when you get a chance. My proposal covers both parts (the opened chat staying in the Unread list and the New line not clearing), and there's a test branch if that helps the review. Happy to answer any questions.
melvin-bot commentedon Oct 21, 2026
@eVoloshchak 6 days overdue. This is scarier than being forced to listen to Vogon poetry!
bconnnnn commentedon Oct 23, 2026
Proposal
What is the root cause of that problem?
The bulk read action may target only rendered items or update a different read-state field from the unread list and badges; the actual handler needs inspection.
What changes do you think we should make in order to solve the problem?
Trace the Unread-tab context menu through bulk mark-read and its optimistic/server state. Target all unread report IDs and reconcile the same fields used by markers, handling failures.
What alternative solutions did you explore? (Optional)
Avoid a UI-only workaround that masks the symptom without fixing the state or lifecycle problem.
Validation: Test multiple offscreen unread chats, counts, offline sync, Android and web.
@eVoloshchak I'm available if you need a contributor for this issue. I'll wait for approval before opening a PR.
melvin-bot commentedon Oct 23, 2026
📣 It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? 💸
melvin-bot commentedon Oct 23, 2026
@eVoloshchak 8 days overdue is a lot. Should this be a Weekly issue? If so, feel free to change it!
melvin-bot commentedon Oct 24, 2026
📣 @Hatem-tmi! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Format:
Hatem-tmi commentedon Oct 24, 2026
Proposal
What is the root cause of that problem?
The Unread list does clear for a moment, then comes back. A chat the action skips never gets that flash, so this is not the Unread-tab predicate disagreeing with the one-transaction thread.
Mark all as read sends one lastReadTime for every selected chat. That time comes from getDBTimeWithSkew. That helper only moves the clock forward when the client is behind the server. When the client is ahead, it sends the client clock unchanged. The server rejects a time in its future, the write fails, and failureData puts the old lastReadTime back. The Unread tab and the badge return together.
The in-chat New marker is a separate gap. It is local state on an open chat. Mark all as read never tells that chat it was read, so the marker can stay after the list is fixed.
What changes do you think we should make in order to solve the problem?
Stop using "now" for this command.
For the batch, send the newest lastVisibleActionCreated among the chats being marked. Those times were already stored by the server, so they are not in the future, and a chat is read once lastReadTime is at least that value. Use the same value for the optimistic update and the request.
For an open chat, also emit the same read event the single "Mark as read" action already emits, so the New marker moves.
Do not change getDBTimeWithSkew. Other writes rely on it never moving backward.
What alternative solutions did you explore?
Use getServerAnchoredDBTime instead. That still means "now". Before the skew is known it falls back to the client clock, which is the value the server is already rejecting.
Make the Unread tab and the action share one isUnread call. That only changes which chats are selected. It does not explain the list emptying and then returning.
Contributor details
Your Expensify account email: hatem.tmi@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0103158c5f0816abbe
melvin-bot commentedon Oct 24, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
mvtglobally commentedon Oct 24, 2026
Issue not reproducible during KI retests. (Third week)
neerajbachani commentedon Oct 24, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On staging Android, long-pressing the Inbox Unread tab and choosing Mark all as read does nothing visible: chats stay in the Unread tab with bold/unread styling, and in-chat New markers remain. Production (9.4.89-0) does not show this; staging (9.4.90-0) does. QA tied it to PR #100693.
What is the root cause of that problem?
This is two separate defects that together produce the reported symptom.
A. Unread chats are not cleared — regression from PR #100693
That PR changed how
markAllMessagesAsReaddecides which reports are unread.Before the PR,
markAllMessagesAsReadcalledisUnread()without a derived-attributes parameter, andisEmptyReport()readREPORT_ATTRIBUTESlive via a module-levelOnyx.connectinsideReportUtils:App/src/libs/actions/Report/MarkAllMessageAsRead.tsx
Lines 63 to 67 in 3caa681
App/src/libs/actions/Report/MarkAllMessageAsRead.tsx
Lines 63 to 67 in 3caa681
App/src/libs/ReportUtils.ts
Lines 9695 to 9707 in 3caa681
App/src/libs/ReportUtils.ts
Lines 9695 to 9707 in 3caa681
After the PR,
isEmptyReport()takes a caller-supplied flag and falls back with??:App/src/libs/ReportUtils.ts
Lines 9689 to 9694 in 2402291
App/src/libs/ReportUtils.ts
Lines 9689 to 9694 in 2402291
That fallback is not equivalent on mobile.
generateIsEmptyReport()returnstruewhen a report has nolastMessageTextand its actions are not in memory — the common state on a freshly launched Android app:App/src/libs/ReportUtils.ts
Lines 9779 to 9790 in 2402291
App/src/libs/ReportUtils.ts
Lines 9779 to 9790 in 2402291
The persisted derived flag can hold
isEmpty: falsewhile a fresh recompute saystrue. When the caller passesundefined(missing snapshot entry),isUnread()returnsfalse, the report is skipped, and the action exits before any write:App/src/libs/actions/Report/MarkAllMessageAsRead.tsx
Lines 67 to 79 in 2402291
App/src/libs/actions/Report/MarkAllMessageAsRead.tsx
Lines 67 to 79 in 2402291
The PR moved derived data to a React render snapshot captured in
InboxTabSelectorand passed at confirm time:App/src/pages/inbox/sidebar/InboxTabSelector.tsx
Lines 47 to 47 in 2402291
App/src/pages/inbox/sidebar/InboxTabSelector.tsx
Line 47 in 2402291
App/src/pages/inbox/sidebar/InboxTabSelector.tsx
Lines 99 to 101 in 2402291
App/src/pages/inbox/sidebar/InboxTabSelector.tsx
Lines 99 to 101 in 2402291
Imperative one-shot actions should not depend on a render snapshot.
UnreadIndicatorUpdaterstill readsREPORT_ATTRIBUTESlive viaOnyx.connectWithoutView, so the badge and the action can disagree about what's unread:App/src/libs/UnreadIndicatorUpdater/index.ts
Lines 68 to 75 in 2402291
App/src/libs/UnreadIndicatorUpdater/index.ts
Lines 68 to 75 in 2402291
Meanwhile the Unread tab itself correctly lists chats using the same derived
isEmptyflag viaSidebarUtils— so the user sees unread chats the action refuses to mark:App/src/libs/SidebarUtils.ts
Lines 987 to 994 in 2402291
App/src/libs/SidebarUtils.ts
Lines 987 to 994 in 2402291
Why Android / staging only: PR #100693 is the only change to
MarkAllMessageAsRead.tsxbetween production 9.4.89-0 and staging 9.4.90-0. Android HybridApp cold-starts with fewer report actions hydrated in memory, which makes thegenerateIsEmptyReportfallback falsely classify real unread chats as empty. Other platforms were not checked in QA; the regression is not Android-specific in code.B. Unread markers are not cleared — pre-existing gap
Even when
markAllMessagesAsReadsucceeds, it only mergeslastReadTime. It never clears manual-unread state or notifies mounted report views.unreadMarkerTimeinuseUnreadMarkeris seeded once at mount and only advanced byunreadAction_*/readNewestAction_*events —markAllMessagesAsReademits neither:App/src/hooks/useUnreadMarker.ts
Lines 82 to 94 in 2402291
App/src/hooks/useUnreadMarker.ts
Lines 82 to 94 in 2402291
manuallyMarkedUnreadReportActionIDis set (typical for the "account with unread messages" precondition), the New line is hard-pinned and ignoreslastReadTime:App/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts
Lines 65 to 67 in 2402291
App/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts
Lines 65 to 67 in 2402291
Only
openReportclearsmanuallyMarkedUnreadReportActionID;readNewestActiondeliberately leaves it alone:App/src/libs/actions/Report/index.ts
Lines 3236 to 3244 in 2402291
App/src/libs/actions/Report/index.ts
Lines 3236 to 3244 in 2402291
What changes do you think we should make in order to solve the problem?
1. Make
markAllMessagesAsReadreadREPORT_ATTRIBUTESlive viaOnyx.connectWithoutView, matchingUnreadIndicatorUpdater. Drop the thirdreportAttributesDerivedparameter and remove the plumbing fromInboxTabSelectorandMarkAllMessagesAsReadHandler.In
src/libs/actions/Report/MarkAllMessageAsRead.tsx, add a live subscription (same pattern asUnreadIndicatorUpdater):Drop the third parameter from the function signature:
Read derived
isEmptyfrom the live subscription inside the loop:Clear manual-unread state in the optimistic merge and roll it back on failure:
Notify mounted report views after building
reportIDList:In
src/pages/inbox/sidebar/InboxTabSelector.tsx, revert to the two-argument call and remove the unuseduseReportAttributes()hook:Apply the same signature change in
MarkAllMessagesAsReadHandler.tsx(remove the ref and third argument).2. Restore the pre-PR
isEmptyReportcontract insrc/libs/ReportUtils.tsso a missing caller value never silently triggers a disagreeing recompute:This mirrors the pre-PR "use cached flag when present" semantics and protects every other Phase-4 callsite that threads
derivedIsEmptyReport.3. Clear manual-unread marker state (changes in item 1 above): merge
manuallyMarkedUnreadReportActionID: nullin the optimistic update and emitreadNewestAction_<reportID>for each marked report so mounted chats drop the New line immediately. Roll back both fields infailureData.Blast radius:
isEmptyReportsemantics change affects all unread/empty checks but restores pre-PR behavior. LHN clearing itself is already correct oncelastReadTimeis written —SidebarUtils.updateReportsToDisplayInLHNrecomputesisUnreadReporton every report change (lines 404–418). The manual-marker clear is scoped to explicit bulk mark-as-read; single-chat auto-read paths remain unchanged.What alternative solutions did you explore? (Optional)
InboxTabSelector(likeMarkAllMessagesAsReadHandler) — still a render snapshot; does not fix the case where the snapshot entry is missing while Onyx hasisEmpty: false. Rejected.getReportIDsForTab(UNREAD)instead of scanning all reports — would mark only what the tab shows, but defect A still makesreportIDListempty whenisUnread()misfires. Does not fix markers. Rejected as primary fix.API.writewhenreportIDListis empty; no request is sent. A backend change cannot help the silent no-op path.updateReportsToDisplayInLHNalready rewritesisUnreadReportwhen unread flags change.What specific scenarios should we cover in automated tests?
markAllMessagesAsReadwithREPORT_ATTRIBUTESpopulated whereisEmpty: falsebut report actions are not in memory — assertsAPI.writeis called and all targeted reports get an updatedlastReadTime.lastMessageTextas read (does not false-negative viagenerateIsEmptyReport).manuallyMarkedUnreadReportActionIDset — asserts optimistic merge clears it andreadNewestAction_<reportID>is emitted.tests/actions/ReportTest.ts:3592passesundefinedfor all three args today; update signature and add the derived-attributes cases above so this regression cannot recur.stacklancer commentedon Oct 24, 2026
@mvtglobally @eVoloshchak I was able to reproduce this on web (main, Oct 17). It only happens to a chat that was opened from the Unread tab, so a plain "Mark all as read" won't show it. Steps:
Root cause and screenshots are in my proposal, and there's a test branch with the fix.
samithreddychinni commentedon Oct 25, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a user switches to the Unread tab in the Inbox, opens or views unread chats, long-presses the Unread tab, and selects Mark all as read, the opened chat remains stuck in the Unread tab list and unread markers remain visible instead of clearing.
What is the root cause of that problem?
There are three distinct causes in the current Inbox tab and
markAllMessagesAsReadimplementation:stickyReportinuseSidebarOrderedReports.tsxis never cleared on "Mark all as read" (src/hooks/useSidebarOrderedReports.tsx:354-378andsrc/pages/inbox/sidebar/InboxTabSelector.tsx:100-113):When a user clicks a chat while on the
UnreadorTo-dotab,setStickyReportID(reportID)storesstickyReport = {reportID, tab: activeTab}. IncomputedFilteredReportIDs(src/hooks/useSidebarOrderedReports.tsx:360-377),stickyReportIDis forcibly kept infilteredReportIDseven afterbaseFilteredReportIDsno longer includes it. When the user triggers Mark all as read inInboxTabSelector.tsx,stickyReportis never reset, so the active chat stays pinned in theUnreadtab list.markAllMessagesAsReaddoes not markoneTransactionThreadReportas read and does not guard againstlastVisibleActionCreated > newLastReadTime(src/libs/actions/Report/MarkAllMessageAsRead.tsx:64-77):In
MarkAllMessageAsRead.tsx:reportIDsis passed (for example from theTo-dotab),reportsToMarkonly iterates over the parent report IDs in the LHN, notoneTransactionThreadReport. IfisUnread(report, oneTransactionThreadReport, ...)returnedtruebecauseoneTransactionThreadReportwas unread, onlyreport.reportIDgetslastReadTimeupdated whileoneTransactionThreadReportstays unread.report.lastVisibleActionCreated(oroneTransactionThreadReport.lastVisibleActionCreated) is slightly ahead ofgetDBTimeWithSkew(), settinglastReadTime: newLastReadTimestill leaveslastReadTime < lastVisibleActionCreated, soisUnreadcontinues to returntrue.The open report's unread marker ("New" line) remains pinned in
ReportActionsList.tsx:In
src/pages/inbox/report/ReportActionsList.tsx,currentUnreadMarkerReportActionIDstays latched for the currently open report while the user views it so the "New" divider does not vanish immediately on open. BecausemarkAllMessagesAsReadupdatesreport.lastReadTimewithout resetting the open report's latchedcurrentUnreadMarkerReportActionID, the green "New" line remains visible inside the open chat.What changes do you think we should make in order to solve the problem?
Expose
clearStickyReportIDfromuseSidebarOrderedReportsActions(src/hooks/useSidebarOrderedReports.tsx) and call it inconfirmMarkAllAsRead(src/pages/inbox/sidebar/InboxTabSelector.tsx):useSidebarOrderedReports.tsx, addclearStickyReportID:InboxTabSelector.tsx(confirmMarkAllAsRead), callclearStickyReportID()when the user confirms Mark all as read on the active tab (or whenmenuTab === activeTabormenuTab !== CONST.INBOX_TAB.TODO).Update
oneTransactionThreadReportand clamplastReadTimeinsrc/libs/actions/Report/MarkAllMessageAsRead.tsx:Clear
currentUnreadMarkerReportActionIDinReportActionsList.tsxwhenreport.lastReadTimeadvances pastreport.lastVisibleActionCreatedwhile the report is already mounted:In
src/pages/inbox/report/ReportActionsList.tsx, whenreport.lastReadTimeupdates from a previous value to>= (report.lastVisibleActionCreated ?? '')and!unreadMarkerTime(no manual "Mark as unread" timestamp), clearcurrentUnreadMarkerReportActionIDso "Mark all as read" removes the "New" line in the open chat as well.What alternative solutions did you explore? (Optional)
I considered clearing
stickyReportinsideuseSidebarOrderedReports.tsxwheneverinboxTabCounts[CONST.INBOX_TAB.UNREAD]drops to0. However, if a user opens the only unread chat on the Unread tab, reading that single chat also drops the unread count to0, andstickyReportis intentionally designed to keep that chat visible until the user switches tabs or triggers a bulk "Mark all as read". ClearingstickyReportexplicitly inconfirmMarkAllAsReadpreserves normal single-chat sticky behavior.Kabeer099 commentedon Oct 25, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When switching to the Unread tab in the Inbox and selecting Mark all as read from the long-press menu, the currently open chat remains visible in the Unread list and unread markers/states on single-transaction thread reports are not completely cleared.
What is the root cause of that problem?
src/hooks/useSidebarOrderedReports.tsx, the sidebar maintains astickyReportstate ({reportID, tab}) so the currently viewed chat stays visible in the filtered list while the user is reading it. WhenconfirmMarkAllAsReadinsrc/pages/inbox/sidebar/InboxTabSelector.tsxcallsmarkAllMessagesAsRead(...), it never clearsstickyReport, so the currently active chat stays pinned inside the Unread tab even after being marked read.src/libs/actions/Report/MarkAllMessageAsRead.tsx(lines 63-75),markAllMessagesAsReadchecksisUnread(report, oneTransactionThreadReport, isReportArchived, reportAttributesDerived?.[report.reportID]?.isEmpty). When a single-transaction parent report appears unread because itsoneTransactionThreadReportis unread (or whenreportIDsis passed for a specific tab),optimisticReportsonly writes{lastReadTime: newLastReadTime}toreport.reportIDwithout also updatingoneTransactionThreadReportwhenoneTransactionThreadReportIDis resolved.What changes do you think we should make in order to solve the problem?
src/hooks/useSidebarOrderedReports.tsxandsrc/pages/inbox/sidebar/InboxTabSelector.tsx, expose aclearStickyReportaction fromuseSidebarOrderedReportsActions()and call it insideconfirmMarkAllAsReadwhenmarkAllMessagesAsReadruns so the active report does not remain stuck in the Unread tab.src/libs/actions/Report/MarkAllMessageAsRead.tsx, whenoneTransactionThreadReportexists and is unread (isUnread(oneTransactionThreadReport, undefined, ...)), include${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReport.reportID}inoptimisticReports,failureReports, andreportIDListalongside the parent report.What alternative solutions did you explore? (Optional)
Automatically clearing
stickyReportinside auseEffectwheneverisUnreadflips tofalsewas considered, but that would break the intended sticky behavior when a user simply clicks into an unread chat to read it normally. ClearingstickyReportspecifically on the explicit Mark all as read action preserves normal single-chat sticky behavior.