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
[Due for payment 2026-06-30] [$250] LHN - Self DM disappears from LHN while distance expense is loading. #92552
Version Number: 9.3.98-0 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:#92351 Email or phone of affected tester (no customers):[email protected] Issue reported by: Applause Internal Team Bug source: Exploratory - Significant User Experience Deterioration Device used: Motorola MotoG60 / Android 12 (Hybrid app) - Windows 11 / Chrome App Component: Left Hand Navigation (LHN)
Action Performed:
Open the staging.new.expensify.com website.
Sign up with a new account and select "Track expenses for my business" option.
Navigate to "Inbox"
Create a map distance expense via FAB and submit it to "Self DM"
Once you land on Self DM, navigate to a different chat. (Or navigate to "Inbox" on Android app)
Note that after navigating out of chat, Self DM disappears from LHN while loading is still visible.
Note that after loading finishes, Self DM reappears on LHN.
Expected Result:
After submitting a track distance expense to Self DM, chat should remain visible on LHN while expense is still loading.
Actual Result:
After submitting a map distance expense to Self DM, chat disappears from LHN until expense finishes loading.
Confidence: Medium. The LHN visibility mechanism below is verified from code; the exact field that flips during the loading window needs one runtime confirmation (called out at the end).
What is the root cause of that problem?
While you are viewing the Self DM, it stays in the LHN only because reasonForReportToBeInOptionList short-circuits and returns IS_FOCUSED for the currently-open report (ReportUtils.ts:9579). The instant you navigate away, the Self DM must qualify for the LHN on its own merits, and the LHN's incremental updater deletes any report that fails the check (SidebarUtils.ts:512-514).
There are two different visibility checks, and they disagree about Self DMs:
The canonical check reasonForReportToBeInOptionList is designed to always keep a Self DM in the LHN (the LHN passes includeSelfDM: true, so it returns IS_SELF_DM at ReportUtils.ts:9725).
But an earlier gate in shouldDisplayReportInLHN runs first and bails out with shouldDisplay: false when isHidden && !shouldOverrideHidden (SidebarUtils.ts:327-339). shouldOverrideHidden does not exempt Self DMs, and requiresAttention is always false for a Self DM track expense (no outstanding child request / no IOU report to approve — getReasonAndReportActionThatRequiresAttention returns null, ReportUtils.ts:4210-4359).
So the Self DM survives the early gate only while its notification preference reads as a visible value (a fresh Self DM is MUTE). getReportNotificationPreference defaults to HIDDEN whenever the current user's participant entry / preference is missing or empty (ReportUtils.ts:1897-1903). During the distance-loading window the Self DM's participants[currentUser].notificationPreference is momentarily read as empty/HIDDEN, so the early gate hides it; once the route finishes loading the participant data is restored to MUTE and the Self DM reappears. This only bites a brand-new account because that's when the Self DM is freshly created with incomplete participant data.
Why it's only visible while open, and why distance (not regular) expenses trigger it
A regular expense's amount is known immediately, but a map distance expense has amount: 0 until the backend computes the route, so the transaction carries pendingFields.waypoints: 'add' for the whole loading window. The LHN re-evaluates the Self DM on navigation during this window.
The Self DM's lastMessageText is optimistically set to "tracking $0.00" (non-empty), so a plain "empty report" check is not the cause — confirming the bug is in the visibility gate, not the empty-chat path.
requiresAttention/isEmpty come from the async-derived REPORT_ATTRIBUTES value (reportAttributes.ts); the transaction update on load-completion re-triggers this recompute, which is what re-adds the Self DM.
What changes do you think we should make in order to solve the problem?
Make the early hidden-gate in shouldDisplayReportInLHN consistent with the canonical reasonForReportToBeInOptionList: a Self DM (with a visible preference per the LHN's includeSelfDM/isSelfDMWithVisiblePreference logic) should never be dropped by the isHidden && !shouldOverrideHidden short-circuit. Concretely, add isSelfDM(report) to the shouldOverrideHidden conditions at SidebarUtils.ts:327-335 so the canonical check at ReportUtils.ts:9725 makes the final call. This keeps the Self DM stable in the LHN regardless of transient participant/preference data during loading.
What alternative solutions did you explore? (Optional)
Hardening the optimistic/server data so the Self DM's participants[currentUser].notificationPreference is never transiently empty during a track-expense request. This addresses the trigger rather than the symptom, but it's broader and harder to scope, and the LHN should be resilient to incomplete data regardless.
Runtime confirmation still needed
Confirm via Onyx state (or the attached video repro) that during the loading window report.participants[currentUserAccountID].notificationPreference on the Self DM is empty/hidden (so isHiddenForCurrentUser returns true) and is restored to mute after the route loads. If the participant data is actually intact the whole time, the disappearance would instead point to a derived-value timing issue in REPORT_ATTRIBUTES, and the fix would target that recompute ordering instead.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/92552) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
🚨 Edited by proposal-police: This proposal was edited at 2026-06-03 17:01:49 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
After tracking a map distance expense to the Self DM, the Self DM row vanishes from the LHN while the expense is still loading (the distance route is being fetched), and only reappears once loading finishes. The chat should stay in the LHN the whole time.
What is the root cause of that problem?
There is exactly one branch in reasonForReportToBeInOptionList that can drop a Self DM from the LHN — the "exclude empty chats" guard:
If the code reaches L9725, a Self DM is always returned (IS_SELF_DM). So the only way it disappears is by hitting the return null at L9722, which requires both:
So the Self DM's presence in the LHN hangs entirely on isEmptyChat being false. And isEmptyChat comes from isEmptyReport, which reads the cached reportAttributes[reportID].isEmpty derived value:
That cached value is produced by generateIsEmptyReport, whose entire notion of "empty" is the last-message text — it has no awareness that the report actually contains an expense:
Why it's distance-specific: distance is the only track-to-Self-DM flow that has a meaningful server round-trip (route computation) keeping the report in the pending/loading state. For a manual track the optimistic data is complete instantly, so the empty re-evaluation window is too short to see. The underlying fragility is the same for any flow: a Self DM with a hidden preference depends on a last-message-derived isEmpty flag that doesn't know the report already holds an expense.
What changes do you think we should make in order to solve the problem?
A Self DM that already contains an expense is never genuinely empty, so it should not be subject to the empty-chat guard — regardless of its notification preference or a transient isEmpty value. I'd broaden isSelfDMWithVisiblePreference to also keep a Self DM that owns a tracked expense (a one-transaction thread). currentReportActions, chatReport and getOneTransactionThreadReportID are already in scope in this function (the latter is used a few lines up at L9696):
- const isSelfDMWithVisiblePreference = isSelfDM(report) && !isHiddenForCurrentUser(report);+ // A Self DM that already holds an expense (e.g. a tracked expense whose distance route is still+ // loading) is not genuinely empty. Keep it in the LHN regardless of notification preference, so it+ // doesn't flicker out while `isEmpty` is transiently true during the pending/loading window.+ const selfDMHasExpense = isSelfDMReport && !!getOneTransactionThreadReportID(report, chatReport, currentReportActions);+ const isSelfDMWithVisiblePreference = isSelfDMReport && (!isHiddenForCurrentUser(report) || selfDMHasExpense);
With selfDMHasExpense === true, !isSelfDMWithVisiblePreference is false, so the guard at L9711-L9723 can't drop the report and execution reaches the IS_SELF_DM return at L9725 — keeping the chat in the LHN for the entire loading window. The optimistic IOU action exists from the moment the expense is created, so getOneTransactionThreadReportID returns the thread throughout loading; the value is stable, so there is no flicker.
Offline: the optimistic transaction/IOU action are present offline, so selfDMHasExpense is true and the row stays — which is the desired offline-first behavior.
Truly empty Self DM:selfDMHasExpense is false, so behavior is unchanged (an empty hidden-preference Self DM is still hidden as today).
Platforms: the LHN filter is shared, so this fixes web, mWeb and the native/hybrid apps in one place.
What alternative solutions did you explore? (Optional)
Make generateIsEmptyReport consider transactions. Correct in spirit, but it runs for every report in a hot derived path and would need transaction lookups; scoping the fix to the Self DM gate is cheaper and lower-risk.
Force the Self DM's notification preference to a visible value when tracking. This masks the symptom by side-stepping isSelfDMWithVisiblePreference, but it changes user-visible notification behavior and still leaves the isEmpty flicker for any other hidden-preference path — wrong layer to fix it.
constisSelfDMWithVisiblePreference=isSelfDM(report)&&!isHiddenForCurrentUser(report);if(excludeEmptyChats&&isEmptyChat&&isChatReport(report)&&!isPolicyExpenseChat(report)&&!isTripRoom(report)&&!isConciergeChatReport(report)&&!isSystemChatReport&&!isSelfDMWithVisiblePreference&&// ← Self DM only escapes when it ALSO has a non-hidden preferencecanHideReport){returnnull;}if(isSelfDMReport){returnincludeSelfDM ? CONST.REPORT_IN_LHN_REASONS.IS_SELF_DM : null;}
Two things line up to make Self DM disappear during the distance-loading window:
isSelfDMWithVisiblePreference is false for this user. A fresh "Track expenses for my business" signup lands with a Self DM whose participant notification preference is HIDDEN (or unset) — isHiddenForCurrentUser treats unset/empty/HIDDEN as hidden — https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L2241-L2250. So the Self-DM exception in the filter doesn't activate. (The Self DM is only kept visible up to this point because it's the current report — that check fires earlier at L9579 — so the user doesn't notice until they navigate away.)
The moment the user navigates off Self DM, the IS_FOCUSED early-return no longer applies, the filter runs, both conditions hit, and Self DM is dropped from the LHN. Once GetRoute resolves, the IOU action's text fills in, lastMessageText becomes truthy, isEmpty flips back to false, and Self DM reappears — matching the reported symptom exactly.
What changes do you think we should make in order to solve the problem?
I think the cleanest fix is to make the "include Self DM" intent explicit and unconditional at this filter, instead of routing it through the brittle isHiddenForCurrentUser check that's sensitive to optimistic-data races. Callers that pass includeSelfDM: true (the LHN path, via SidebarUtils.shouldDisplayReportInLHN at https://github.com/Expensify/App/blob/main/src/libs/SidebarUtils.ts#L341-L355 ) are asking for Self DM to be kept regardless of empty/hidden status; that's its semantics in the LHN.
That single line keeps Self DM visible in the LHN throughout the distance-loading window without changing behavior for the other callers (search, participant pickers) that pass includeSelfDM: false — they still get the existing visibility-preference gating.
If we'd rather not widen the meaning of isSelfDMWithVisiblePreference, the equivalent is to hoist the Self DM short-circuit above the empty-chat block:
Both produce the same observable outcome. I'd go with the first diff because it's a one-line change with the narrowest blast radius.
No accompanying changes are needed in TrackExpense.ts or the reportAttributes derivation — the underlying race (an optimistic action whose text only fills in after a follow-up API call) exists for other transaction types too (scan/SmartScan), and trying to paper over it in the chat-report lastMessageText would require touching every transaction-type-specific text builder.
Tests: the existing LHN visibility tests in tests/unit/SidebarTest.ts already cover "Self DM is shown in LHN" — I'd add one case where lastMessageText is empty AND the Self DM's notification preference is HIDDEN, asserting the report still appears when called via the LHN path (includeSelfDM: true).
What alternative solutions did you explore? (Optional)
Set the Self DM's participant notificationPreference to ALWAYS optimistically inside buildOnyxDataForTrackExpense whenever a track expense is created in Self DM. Rejected — it papers over the symptom (isSelfDMWithVisiblePreference becomes true so the filter doesn't trip) but mutates a user-controlled preference as a side effect of submitting an expense, which is wrong: the user may have deliberately hidden their Self DM, and a transaction shouldn't silently un-hide it. It also doesn't help non-distance flows that hit the same race (e.g. the same disappearance can occur for SmartScan to Self DM while OCR is processing).
Always write a non-empty placeholder lastMessageText (e.g. translated "Distance request") into the chat report optimistically and only overwrite it once GetRoute returns. Rejected — it puts UI-string concerns into the action layer, the placeholder would briefly appear in the LHN row preview which is a worse UX than the current "no preview yet" state, and it doesn't address the underlying filter logic (the next regression in this area would rediscover the same bug).
Force the LHN to keep Self DM cached in currentReportsToDisplay across recomputes when there's any in-flight transaction targeting it. Rejected — couples the LHN to transaction-pending state across modules, requires plumbing a new dependency through useSidebarOrderedReports, and the bug isn't really about caching: a fresh subscriber computing the LHN from scratch during the loading window would also drop Self DM.
The regression was caught after PR #92351, which only bumped react-native-onyx from 3.0.75 to 3.0.79: #92351
The relevant Onyx change between those versions is mergeCollectionWithPatches becoming cache-first. That means collection subscribers now reliably see intermediate optimistic collection updates before storage finishes: Expensify/react-native-onyx@42b796d64f
That exposed an existing inconsistency in our track-expense optimistic data.
For track distance expenses to Self DM, useExpenseSubmission calls trackExpenseIOUActions:
In buildOnyxDataForTrackExpense, we first optimistically merge the Self DM report with the new IOU preview text, but keep the old lastVisibleActionCreated:
Before the Onyx bump, this intermediate incomplete report state was easier to miss. After the cache-first Onyx change, LHN can recompute from the first Self DM report merge, where the row has new message data but stale visibility/sort metadata. So when the user navigates away while the map distance expense is still loading, LHN temporarily drops Self DM until the final server/optimistic state catches up.
What changes do you think we should make in order to solve the problem?
Make the first optimistic Self DM report merge complete. For Self DM track expenses, use the actionable whisper timestamp, or at least the IOU action timestamp, instead of preserving the old lastVisibleActionCreated.
I’d also add a small regression test in tests/actions/IOUTest/TrackExpenseTest.ts to verify the Self DM report is LHN-ready immediately after the optimistic track expense update:
What alternative solutions did you explore? (Optional)
We could change LHN to tolerate this transient Self DM state, but that would make LHN compensate for bad optimistic data. The smaller and safer fix is to make the optimistic Self DM report internally consistent before Onyx notifies subscribers.
changed the title [-]LHN - Self DM disappears from LHN while distance expense is loading.[/-][+][$250] LHN - Self DM disappears from LHN while distance expense is loading.[/+]on Jun 3, 2026
⚠️@yusufdeveloper2903 Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).
[Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion: N/A
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
[BugZero Assignee] Create a GH issue for creating/updating the regression test once above steps have been agreed upon.
Sign up with a new account and complete onboarding with each of these options:
- Track expenses for my business
- Manage my team's expenses > 11-50 employees
- Something else
Navigate to Inbox.
Create a map distance expense from the FAB and submit it to Self DM.
Before the distance expense finishes loading, navigate away from Self DM.
Verify Self DM remains visible in the LHN while the distance expense is loading.
Activity
applause-bot commentedon Jun 3, 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/fa24c1dd492a2c7b131e77bcc276ada4f2c75a4b90f6d9803d0848baa8cbdb87
MelvinBot commentedon Jun 3, 2026
Proposal
What is the root cause of that problem?
While you are viewing the Self DM, it stays in the LHN only because
reasonForReportToBeInOptionListshort-circuits and returnsIS_FOCUSEDfor the currently-open report (ReportUtils.ts:9579). The instant you navigate away, the Self DM must qualify for the LHN on its own merits, and the LHN's incremental updater deletes any report that fails the check (SidebarUtils.ts:512-514).There are two different visibility checks, and they disagree about Self DMs:
reasonForReportToBeInOptionListis designed to always keep a Self DM in the LHN (the LHN passesincludeSelfDM: true, so it returnsIS_SELF_DMatReportUtils.ts:9725).shouldDisplayReportInLHNruns first and bails out withshouldDisplay: falsewhenisHidden && !shouldOverrideHidden(SidebarUtils.ts:327-339).shouldOverrideHiddendoes not exempt Self DMs, andrequiresAttentionis alwaysfalsefor a Self DM track expense (no outstanding child request / no IOU report to approve —getReasonAndReportActionThatRequiresAttentionreturnsnull,ReportUtils.ts:4210-4359).So the Self DM survives the early gate only while its notification preference reads as a visible value (a fresh Self DM is
MUTE).getReportNotificationPreferencedefaults toHIDDENwhenever the current user's participant entry / preference is missing or empty (ReportUtils.ts:1897-1903). During the distance-loading window the Self DM'sparticipants[currentUser].notificationPreferenceis momentarily read as empty/HIDDEN, so the early gate hides it; once the route finishes loading the participant data is restored toMUTEand the Self DM reappears. This only bites a brand-new account because that's when the Self DM is freshly created with incomplete participant data.Why it's only visible while open, and why distance (not regular) expenses trigger it
amount: 0until the backend computes the route, so the transaction carriespendingFields.waypoints: 'add'for the whole loading window. The LHN re-evaluates the Self DM on navigation during this window.lastMessageTextis optimistically set to"tracking $0.00"(non-empty), so a plain "empty report" check is not the cause — confirming the bug is in the visibility gate, not the empty-chat path.requiresAttention/isEmptycome from the async-derivedREPORT_ATTRIBUTESvalue (reportAttributes.ts); the transaction update on load-completion re-triggers this recompute, which is what re-adds the Self DM.What changes do you think we should make in order to solve the problem?
Make the early hidden-gate in
shouldDisplayReportInLHNconsistent with the canonicalreasonForReportToBeInOptionList: a Self DM (with a visible preference per the LHN'sincludeSelfDM/isSelfDMWithVisiblePreferencelogic) should never be dropped by theisHidden && !shouldOverrideHiddenshort-circuit. Concretely, addisSelfDM(report)to theshouldOverrideHiddenconditions atSidebarUtils.ts:327-335so the canonical check atReportUtils.ts:9725makes the final call. This keeps the Self DM stable in the LHN regardless of transient participant/preference data during loading.What alternative solutions did you explore? (Optional)
Hardening the optimistic/server data so the Self DM's
participants[currentUser].notificationPreferenceis never transiently empty during a track-expense request. This addresses the trigger rather than the symptom, but it's broader and harder to scope, and the LHN should be resilient to incomplete data regardless.Runtime confirmation still needed
Confirm via Onyx state (or the attached video repro) that during the loading window
report.participants[currentUserAccountID].notificationPreferenceon the Self DM is empty/hidden(soisHiddenForCurrentUserreturnstrue) and is restored tomuteafter the route loads. If the participant data is actually intact the whole time, the disappearance would instead point to a derived-value timing issue inREPORT_ATTRIBUTES, and the fix would target that recompute ordering instead.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/92552)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
yusufdeveloper2903 commentedon Jun 3, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-06-03 17:01:49 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
After tracking a map distance expense to the Self DM, the Self DM row vanishes from the LHN while the expense is still loading (the distance route is being fetched), and only reappears once loading finishes. The chat should stay in the LHN the whole time.
What is the root cause of that problem?
There is exactly one branch in
reasonForReportToBeInOptionListthat can drop a Self DM from the LHN — the "exclude empty chats" guard:https://github.com/Expensify/App/blob/8020a9338e8/src/libs/ReportUtils.ts#L9709-L9727
If the code reaches
L9725, a Self DM is always returned (IS_SELF_DM). So the only way it disappears is by hitting thereturn nullatL9722, which requires both:isEmptyChat === true, and!isSelfDMWithVisiblePreference— i.e. the Self DM's notification preference resolves to hidden. For a freshly-onboarded account the participant has no explicit preference, andgetReportNotificationPreferencefalls back toHIDDEN:https://github.com/Expensify/App/blob/8020a9338e8/src/libs/ReportUtils.ts#L1900-L1902
So the Self DM's presence in the LHN hangs entirely on
isEmptyChatbeingfalse. AndisEmptyChatcomes fromisEmptyReport, which reads the cachedreportAttributes[reportID].isEmptyderived value:https://github.com/Expensify/App/blob/8020a9338e8/src/libs/ReportUtils.ts#L8876-L8881
That cached value is produced by
generateIsEmptyReport, whose entire notion of "empty" is the last-message text — it has no awareness that the report actually contains an expense:https://github.com/Expensify/App/blob/8020a9338e8/src/libs/ReportUtils.ts#L9023-L9034
A brand-new Self DM is created with
lastMessageText: undefined(https://github.com/Expensify/App/blob/8020a9338e8/src/libs/ReportUtils.ts#L6577). While a distance expense is loading, its transaction still carriespendingFields.waypoints(isFetchingWaypointsFromServer— https://github.com/Expensify/App/blob/8020a9338e8/src/libs/TransactionUtils/index.ts#L1057-L1059) and the merchant is thePending…placeholder. During that window the Self DM's derivedisEmptyattribute (recomputed off the report/action/transaction churn — https://github.com/Expensify/App/blob/8020a9338e8/src/libs/actions/OnyxDerived/configs/reportAttributes.ts#L361) transiently resolves back totrue, so the guard above removes the row; once the route resolves and the attribute settles,isEmptyflips tofalseand the row returns.Why it's distance-specific: distance is the only track-to-Self-DM flow that has a meaningful server round-trip (route computation) keeping the report in the
pending/loading state. For a manual track the optimistic data is complete instantly, so the empty re-evaluation window is too short to see. The underlying fragility is the same for any flow: a Self DM with a hidden preference depends on a last-message-derivedisEmptyflag that doesn't know the report already holds an expense.What changes do you think we should make in order to solve the problem?
A Self DM that already contains an expense is never genuinely empty, so it should not be subject to the empty-chat guard — regardless of its notification preference or a transient
isEmptyvalue. I'd broadenisSelfDMWithVisiblePreferenceto also keep a Self DM that owns a tracked expense (a one-transaction thread).currentReportActions,chatReportandgetOneTransactionThreadReportIDare already in scope in this function (the latter is used a few lines up atL9696):With
selfDMHasExpense === true,!isSelfDMWithVisiblePreferenceisfalse, so the guard atL9711-L9723can't drop the report and execution reaches theIS_SELF_DMreturn atL9725— keeping the chat in the LHN for the entire loading window. The optimistic IOU action exists from the moment the expense is created, sogetOneTransactionThreadReportIDreturns the thread throughout loading; the value is stable, so there is no flicker.selfDMHasExpenseistrueand the row stays — which is the desired offline-first behavior.selfDMHasExpenseisfalse, so behavior is unchanged (an empty hidden-preference Self DM is still hidden as today).What alternative solutions did you explore? (Optional)
generateIsEmptyReportconsider transactions. Correct in spirit, but it runs for every report in a hot derived path and would need transaction lookups; scoping the fix to the Self DM gate is cheaper and lower-risk.isSelfDMWithVisiblePreference, but it changes user-visible notification behavior and still leaves theisEmptyflicker for any other hidden-preference path — wrong layer to fix it.lastMessageTextharder in the optimistic data. The optimistic write already sets it (https://github.com/Expensify/App/blob/8020a9338e8/src/libs/actions/IOU/TrackExpense.ts#L262, https://github.com/Expensify/App/blob/8020a9338e8/src/libs/actions/IOU/TrackExpense.ts#L295); the disappearance is the cachedisEmptyattribute briefly going stale during the loading churn, so chasing the last-message value is fragile. Keying visibility off "the report owns an expense" is the robust invariant.David-2002065 commentedon Jun 3, 2026
Proposal
What is the root cause of that problem?
The Self DM is being filtered out by the "exclude empty chats" branch in
reasonForReportToBeInOptionList, here:https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L9709-L9727
Two things line up to make Self DM disappear during the distance-loading window:
isSelfDMWithVisiblePreferenceisfalsefor this user. A fresh "Track expenses for my business" signup lands with a Self DM whose participant notification preference isHIDDEN(or unset) —isHiddenForCurrentUsertreats unset/empty/HIDDENas hidden — https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L2241-L2250. So the Self-DM exception in the filter doesn't activate. (The Self DM is only kept visible up to this point because it's the current report — that check fires earlier at L9579 — so the user doesn't notice until they navigate away.)isEmptyChatis brieflytrueduring the map-distance loading.isEmptyReportreads from the cachedreportAttributesDerivedValuefirst — https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L8876-L8888 — and only falls back toreport.lastMessageTextif no attributes entry exists. For the optimistic distance flow,buildOnyxDataForTrackExpensewriteslastMessageTextfromgetReportActionText(iouAction)on the chat report — https://github.com/Expensify/App/blob/main/src/libs/actions/IOU/TrackExpense.ts#L255-L269 — but the action text for an in-flight distance request resolves through a translation that returns an empty/placeholder until the waypoint route comes back from the server (pendingFields.waypoints = ADDis set on the transaction at https://github.com/Expensify/App/blob/main/src/libs/actions/IOU/TrackExpense.ts#L1067 ). So during the loading window, the IOU action text — and therefore Self DM'slastMessageText— is empty, and the derivedisEmptyflips totrue.The moment the user navigates off Self DM, the
IS_FOCUSEDearly-return no longer applies, the filter runs, both conditions hit, and Self DM is dropped from the LHN. OnceGetRouteresolves, the IOU action's text fills in,lastMessageTextbecomes truthy,isEmptyflips back tofalse, and Self DM reappears — matching the reported symptom exactly.What changes do you think we should make in order to solve the problem?
I think the cleanest fix is to make the "include Self DM" intent explicit and unconditional at this filter, instead of routing it through the brittle
isHiddenForCurrentUsercheck that's sensitive to optimistic-data races. Callers that passincludeSelfDM: true(the LHN path, viaSidebarUtils.shouldDisplayReportInLHNat https://github.com/Expensify/App/blob/main/src/libs/SidebarUtils.ts#L341-L355 ) are asking for Self DM to be kept regardless of empty/hidden status; that's its semantics in the LHN.In
reasonForReportToBeInOptionList:That single line keeps Self DM visible in the LHN throughout the distance-loading window without changing behavior for the other callers (search, participant pickers) that pass
includeSelfDM: false— they still get the existing visibility-preference gating.If we'd rather not widen the meaning of
isSelfDMWithVisiblePreference, the equivalent is to hoist the Self DM short-circuit above the empty-chat block:Both produce the same observable outcome. I'd go with the first diff because it's a one-line change with the narrowest blast radius.
No accompanying changes are needed in
TrackExpense.tsor thereportAttributesderivation — the underlying race (an optimistic action whose text only fills in after a follow-up API call) exists for other transaction types too (scan/SmartScan), and trying to paper over it in the chat-reportlastMessageTextwould require touching every transaction-type-specific text builder.Tests: the existing LHN visibility tests in
tests/unit/SidebarTest.tsalready cover "Self DM is shown in LHN" — I'd add one case wherelastMessageTextis empty AND the Self DM's notification preference isHIDDEN, asserting the report still appears when called via the LHN path (includeSelfDM: true).What alternative solutions did you explore? (Optional)
notificationPreferencetoALWAYSoptimistically insidebuildOnyxDataForTrackExpensewhenever a track expense is created in Self DM. Rejected — it papers over the symptom (isSelfDMWithVisiblePreferencebecomes true so the filter doesn't trip) but mutates a user-controlled preference as a side effect of submitting an expense, which is wrong: the user may have deliberately hidden their Self DM, and a transaction shouldn't silently un-hide it. It also doesn't help non-distance flows that hit the same race (e.g. the same disappearance can occur for SmartScan to Self DM while OCR is processing).lastMessageText(e.g. translated "Distance request") into the chat report optimistically and only overwrite it onceGetRoutereturns. Rejected — it puts UI-string concerns into the action layer, the placeholder would briefly appear in the LHN row preview which is a worse UX than the current "no preview yet" state, and it doesn't address the underlying filter logic (the next regression in this area would rediscover the same bug).currentReportsToDisplayacross recomputes when there's any in-flight transaction targeting it. Rejected — couples the LHN to transaction-pending state across modules, requires plumbing a new dependency throughuseSidebarOrderedReports, and the bug isn't really about caching: a fresh subscriber computing the LHN from scratch during the loading window would also drop Self DM.mukhrr commentedon Jun 3, 2026
Proposal
What is the root cause of that problem?
The regression was caught after PR
#92351, which only bumpedreact-native-onyxfrom3.0.75to3.0.79:#92351
The relevant Onyx change between those versions is
mergeCollectionWithPatchesbecoming cache-first. That means collection subscribers now reliably see intermediate optimistic collection updates before storage finishes:Expensify/react-native-onyx@42b796d64f
That exposed an existing inconsistency in our track-expense optimistic data.
For track distance expenses to Self DM,
useExpenseSubmissioncallstrackExpenseIOUActions:App/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
Lines 603 to 645 in d01f4e0
In
buildOnyxDataForTrackExpense, we first optimistically merge the Self DM report with the new IOU preview text, but keep the oldlastVisibleActionCreated:App/src/libs/actions/IOU/TrackExpense.ts
Lines 261 to 267 in d01f4e0
Then, for Self DM, we later merge the actionable whisper and update
lastVisibleActionCreated:App/src/libs/actions/IOU/TrackExpense.ts
Lines 289 to 295 in d01f4e0
Before the Onyx bump, this intermediate incomplete report state was easier to miss. After the cache-first Onyx change, LHN can recompute from the first Self DM report merge, where the row has new message data but stale visibility/sort metadata. So when the user navigates away while the map distance expense is still loading, LHN temporarily drops Self DM until the final server/optimistic state catches up.
What changes do you think we should make in order to solve the problem?
Make the first optimistic Self DM report merge complete. For Self DM track expenses, use the actionable whisper timestamp, or at least the IOU action timestamp, instead of preserving the old
lastVisibleActionCreated.Small diff:
I’d also add a small regression test in
tests/actions/IOUTest/TrackExpenseTest.tsto verify the Self DM report is LHN-ready immediately after the optimistic track expense update:What alternative solutions did you explore? (Optional)
We could change LHN to tolerate this transient Self DM state, but that would make LHN compensate for bad optimistic data. The smaller and safer fix is to make the optimistic Self DM report internally consistent before Onyx notifies subscribers.
melvin-bot commentedon Jun 3, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @sobitneupane (
External)melvin-bot commentedon Jun 3, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022062214988244884169
[-]LHN - Self DM disappears from LHN while distance expense is loading.[/-][+][$250] LHN - Self DM disappears from LHN while distance expense is loading.[/+]github-actions commentedon Jun 3, 2026
42 remaining items
melvin-bot commentedon Jun 29, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Jun 29, 2026
Payment Summary
Upwork Job
BugZero Checklist (@mallenexpensify)
sobitneupane commentedon Jul 1, 2026
BugZero Checklist:
[Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
Link to the comment on the PR: Bump Onyx to 3.0.79 #92351 (comment)
Link to the comment on the Issue:
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion: N/A
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
[BugZero Assignee] Create a GH issue for creating/updating the regression test once above steps have been agreed upon.
Link to issue: https://github.com/Expensify/Expensify/issues/655600
Regression Test Proposal
Test:
- Track expenses for my business
- Manage my team's expenses > 11-50 employees
- Something else
Do we agree 👍 or 👎
Zapier Logs
Run ID: 00040eee-b453-a3d2-b4c5-fb756d866f94mallenexpensify commentedon Jul 2, 2026
Payment Summary
Contributor: @KJ21-ENG paid $250 via Upwork
Contributor+: @sobitneupane due $250 via NewDot
@KJ21-ENG can you please accept the job below? Please reply here and tag me once you have.
https://www.upwork.com/jobs/~022062214988244884169
^ Test case created. Thx
KJ21-ENG commentedon Jul 2, 2026
@mallenexpensify Accepted, Thanks!
mallenexpensify commentedon Jul 2, 2026
@KJ21-ENG paid, summary updated above. Thx.
KJ21-ENG commentedon Jul 2, 2026
Thanks!❤️
JmillsExpensify commentedon Jul 28, 2026
$250 approved for @sobitneupane