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-08-07] [$250] Expense - Two different contacts are selected when trying to submit IOU. #90607
Version Number: 9.3.73-3 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:https://test-management.browserstack.com/projects/2219752/test-runs/TR-28526/folder/13177049/41237809/1410971128 Email or phone of affected tester (no customers):ibellicotest+2250@gmail.com 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: Money Requests
Action Performed:
Prerequisite: Two different users available in "Contacts" list.
Open the staging.new.expensify.com wesbite.
Click on FAB > "Create Expense"
Enter amount > "Next"
Select any user from "Contacts" list
On details page, click on "To" field again.
Note that two different users were selected.
Click on "Next"
Note that you land on a "It´s not here" page.
Expected Result:
Only the selected user should be displayed with a checkmark during expense creation flow.
Actual Result:
After selecting one user to submit an IOU to, two different contacts are displayed with a checkmark, and clicking on "Next" leads to "It´s not here" page.
🚨 Edited by proposal-police: This proposal was edited at 2026-07-17 17:37:40 UTC.
Proposal
Updated 2026-07-17: Refined to high confidence after verifying the exact dedup mechanism. This supersedes my earlier draft. wildan-m's proposal below independently identifies the same root cause and their fix direction is sound (one correction: the introducing PR is #87920, not #88820).
What is the root cause of that problem?
The participants picker dedupes the already-selected participant out of the Recents/Contacts sections using a single "best" identifier key, but the code that marks a row selected matches on any of three identifiers. These two strategies disagree for a Contacts pick, so the row is kept and checked → two checkmarks, and Next lands on NotFoundPage.
Concretely:
The dedup key is reportID → accountID → login → phoneNumber (first non-empty wins) — a single string.
A Contacts pick has no reportID, so its key is the accountID. But the same person's Recents row has a real reportID, so its key is the reportID — a different string. The dedup filter never matches, so the recents row survives.
Meanwhile the selection tagger matches on accountIDORreportIDORlogin, so it still marks that surviving recents row isSelected: true.
Net: two rows for the same user, both checked, while the picker is still single-select. With 2 "selected" participants, the submit path reverts to a placeholder reportID that resolves to no writable report, and the confirmation step's guard renders NotFoundPage ("It's not here").
This is an incomplete fix / regression of the "renders selected items twice" fix that added this single-key dedup.
Introducing change: commit 4565001b ("fix: Participants picker renders selected items twice"), merged via #87920. All frontend — no backend involvement.
One point worth a runtime confirmation: the base selector also filters isSelected rows out of availableOptions (base.ts:381), yet the recents row still survives in practice — the exact field that lets it slip past (e.g. an accountID that stringifies differently or is absent on the recents row) is best pinned with a live repro inspecting participants[0] vs the recents row's {reportID, accountID, login}.
What changes do you think we should make in order to solve the problem?
Make the dedup use the same multi-identifier match the selection tagger already uses, instead of a single collapsed key:
In ParticipantSearchResults.tsx, replace the single selectedParticipantKeys string Set with a predicate mirroring doOptionsMatch — an option is "already selected" if its accountID, reportID (excluding the '-1' sentinel), orlogin matches the selected participant.
Apply that predicate to all three sections (workspace / recents / contacts), and also inside the initiallySelectedReportID branch so the move-to-top path can't leave a second occurrence behind.
This keeps the dedup and the tagger in sync and closes the whole family of "different identifier on each side" duplicates, not just this instance.
What alternative solutions did you explore? (Optional)
A composite string key ("${reportID}|${accountID}|${login}") would fix this specific case but keeps the brittle "match one key out of many" semantics. Aligning the dedup with doOptionsMatch is the durable fix.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/90607) 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-07-17 22:46:46 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the Create Expense flow, after picking a recipient from the Contacts section and returning to the participants picker (by tapping the "To" field on the confirmation page), the picker shows two list rows for the same user with the selected checkmark — one at the top of the list and another in the Recents section. Tapping "Next" then routes the flow into the "It's not here" page because the screen is mounted in single-select mode but two rows are marked selected.
What is the root cause of that problem?
The dedup logic added in PR #88820 to fix "Participants picker renders selected items twice" is keyed on a single best-of-many identifier, and the two endpoints of the comparison can pick different identifiers for the same user, so the dedup silently misses.
The participants picker computes a key for the currently-selected participant and uses it as the filter key against every row coming back from useSearchSelector:
getParticipantOptionKey returns the first non-empty value out of reportID → accountID → login → phoneNumber. When the user picks a row from the Contacts section, that row has no reportID (no existing 1:1 chat yet), so addSingleParticipant stores a participant with only accountID/login. Re-entering the picker, the selected participant therefore yields the key accountID-as-string. However, the same person also surfaces in the Recents section if any prior chat with them exists — and that row carries a real reportID. For the recents row getParticipantOptionKey returns the reportID, which is a different string. The selectedParticipantKeys Set never contains that reportID, so the filter keeps the duplicate row.
The picker also marks that duplicate row visually selected. useSearchSelector seeds selectedOptions from the participants prop and tags any base option whose accountID/reportID/login matches one of those selected entries with isSelected: true:
(option1.accountID&&option1.accountID===option2.accountID)||// eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison
Because that match uses any of the three identifiers, the same user's recent-report row is correctly tagged isSelected: true. So the dedup filter that should have removed the row never fires (single-key match), but the selection tagger that should leave it alone tags it anyway (multi-key match). The end state is two rendered rows for the same person, both with checkmarks, with the picker still in single-select mode. Submitting that state lands the navigator on the "It's not here" route because the downstream flow expects exactly one selected participant.
The mirror condition (recents-picked participant carrying a reportID but the duplicate side has only accountID) cannot occur today because every pick from Recents has a reportID, but the asymmetry of using one of three identifiers for matching makes the dedup brittle either way.
What changes do you think we should make in order to solve the problem?
Make the dedup match the same set of identifiers that the selection tagger already uses — accountID, reportID, and login — instead of collapsing them to a single best-key string. Concretely, in ParticipantSearchResults.tsx:
Replace the single selectedParticipantKeys Set with three Sets built from the participants prop: one of accountIDs, one of reportIDs (excluding the sentinel '-1' used for name-only attendees), and one of logins.
Replace the existing per-section .filter((option) => !selectedParticipantKeys.has(getParticipantOptionKey(option))) with a predicate that returns true only when none of the option's accountID/reportID/login appear in the corresponding Set. The predicate mirrors the doOptionsMatch policy already used by useSearchSelector so the two stay in sync — same identifiers, same precedence.
The initiallySelectedReportID path (used when the participant is restored from Recents, where the participants prop is the empty array) is left unchanged: its move-to-top / tagging logic already keys on reportID, which is the only identifier available in that case, so the new predicate would not change its behavior there.
After this, getParticipantOptionKey becomes unused; it can be removed since no other caller depends on it.
Verification: the scenario in the bug report (Create Expense → Next → pick a contact who also has a recent chat → tap "To" on the confirmation page) must show exactly one checkmark and exactly one row for the picked user, and the mirror scenario (pick from Recents, return) must continue to show one checkmark with the user pinned at the top of the list.
What alternative solutions did you explore? (Optional)
A narrower fix is to teach getParticipantOptionKey to emit a composite key ("${reportID ?? ''}|${accountID ?? ''}|${login ?? ''}") and dedup on string equality of that composite. This is cheaper to write but leaves the dedup function still semantically "match one key out of many" — any future option shape that fills different identifier slots on each side reintroduces the same class of bug. Aligning the dedup with doOptionsMatch is the change that closes the family of bugs, not just this instance.
A second alternative is to set shouldKeepSelectedInAvailableOptions: true on the useSearchSelector call so the recents/contacts row is allowed to remain but tagged correctly, and rely entirely on the SelectionList to render a single checkmark. That defers responsibility into shared selector state and changes behavior for paths that currently rely on the items being filtered out (the SelectionList still renders the duplicate row, just with one checkmark instead of two — visually misleading). Filtering at the participants-picker layer keeps the visible list correct.
changed the title [-]Expense - Two different contacts are selected when trying to submit IOU.[/-][+][$250] Expense - Two different contacts are selected when trying to submit IOU.[/+]on Jul 17, 2026
Activity
MelvinBot commentedon Jul 17, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-07-17 17:37:40 UTC.
Proposal
Updated 2026-07-17: Refined to high confidence after verifying the exact dedup mechanism. This supersedes my earlier draft.
wildan-m's proposal below independently identifies the same root cause and their fix direction is sound (one correction: the introducing PR is #87920, not #88820).What is the root cause of that problem?
The participants picker dedupes the already-selected participant out of the Recents/Contacts sections using a single "best" identifier key, but the code that marks a row selected matches on any of three identifiers. These two strategies disagree for a Contacts pick, so the row is kept and checked → two checkmarks, and Next lands on
NotFoundPage.Concretely:
reportID → accountID → login → phoneNumber(first non-empty wins) — a single string.reportID, so its key is the accountID. But the same person's Recents row has a realreportID, so its key is the reportID — a different string. The dedup filter never matches, so the recents row survives.accountIDORreportIDORlogin, so it still marks that surviving recents rowisSelected: true.reportIDthat resolves to no writable report, and the confirmation step's guard rendersNotFoundPage("It's not here").This is an incomplete fix / regression of the "renders selected items twice" fix that added this single-key dedup.
Code references & verified evidence (SHA
e4ae3a2)Two-checkmark asymmetry:
ParticipantSearchResults.tsx:295, applied at:313,:337,:345reportID ?? accountID ?? login ?? phoneNumber ?? '':ParticipantSearchResults.tsx:141doOptionsMatch: accountID OR reportID OR login):useSearchSelector/base.ts:157-163, applied via:361-376NotFoundPage on Next:
useParticipantSubmission.ts:290-295withWritableReportOrNotFound.tsx:119-121Introducing change: commit
4565001b("fix: Participants picker renders selected items twice"), merged via #87920. All frontend — no backend involvement.One point worth a runtime confirmation: the base selector also filters
isSelectedrows out ofavailableOptions(base.ts:381), yet the recents row still survives in practice — the exact field that lets it slip past (e.g. anaccountIDthat stringifies differently or is absent on the recents row) is best pinned with a live repro inspectingparticipants[0]vs the recents row's{reportID, accountID, login}.What changes do you think we should make in order to solve the problem?
Make the dedup use the same multi-identifier match the selection tagger already uses, instead of a single collapsed key:
ParticipantSearchResults.tsx, replace the singleselectedParticipantKeysstring Set with a predicate mirroringdoOptionsMatch— an option is "already selected" if itsaccountID,reportID(excluding the'-1'sentinel), orloginmatches the selected participant.initiallySelectedReportIDbranch so the move-to-top path can't leave a second occurrence behind.This keeps the dedup and the tagger in sync and closes the whole family of "different identifier on each side" duplicates, not just this instance.
What alternative solutions did you explore? (Optional)
A composite string key (
"${reportID}|${accountID}|${login}") would fix this specific case but keeps the brittle "match one key out of many" semantics. Aligning the dedup withdoOptionsMatchis the durable fix.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/90607)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
melvin-bot commentedon Jul 17, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @thelullabyy (
External)wildan-m commentedon Jul 17, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-07-17 22:46:46 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the Create Expense flow, after picking a recipient from the Contacts section and returning to the participants picker (by tapping the "To" field on the confirmation page), the picker shows two list rows for the same user with the selected checkmark — one at the top of the list and another in the Recents section. Tapping "Next" then routes the flow into the "It's not here" page because the screen is mounted in single-select mode but two rows are marked selected.
What is the root cause of that problem?
The dedup logic added in PR #88820 to fix "Participants picker renders selected items twice" is keyed on a single best-of-many identifier, and the two endpoints of the comparison can pick different identifiers for the same user, so the dedup silently misses.
The participants picker computes a key for the currently-selected participant and uses it as the filter key against every row coming back from useSearchSelector:
App/src/pages/iou/request/ParticipantSearchResults.tsx
Line 122 in 1e83f0b
That filter is then applied to the Workspace, Recents, and Contacts sections:
App/src/pages/iou/request/ParticipantSearchResults.tsx
Lines 295 to 328 in 1e83f0b
getParticipantOptionKeyreturns the first non-empty value out ofreportID → accountID → login → phoneNumber. When the user picks a row from the Contacts section, that row has noreportID(no existing 1:1 chat yet), soaddSingleParticipantstores a participant with onlyaccountID/login. Re-entering the picker, the selected participant therefore yields the keyaccountID-as-string. However, the same person also surfaces in the Recents section if any prior chat with them exists — and that row carries a realreportID. For the recents rowgetParticipantOptionKeyreturns thereportID, which is a different string. TheselectedParticipantKeysSet never contains thatreportID, so the filter keeps the duplicate row.The picker also marks that duplicate row visually selected. useSearchSelector seeds
selectedOptionsfrom theparticipantsprop and tags any base option whoseaccountID/reportID/loginmatches one of those selected entries withisSelected: true:App/src/hooks/useSearchSelector.base.ts
Lines 145 to 151 in 1e83f0b
App/src/hooks/useSearchSelector.base.ts
Lines 339 to 350 in 1e83f0b
Because that match uses any of the three identifiers, the same user's recent-report row is correctly tagged
isSelected: true. So the dedup filter that should have removed the row never fires (single-key match), but the selection tagger that should leave it alone tags it anyway (multi-key match). The end state is two rendered rows for the same person, both with checkmarks, with the picker still in single-select mode. Submitting that state lands the navigator on the "It's not here" route because the downstream flow expects exactly one selected participant.The mirror condition (recents-picked participant carrying a
reportIDbut the duplicate side has onlyaccountID) cannot occur today because every pick from Recents has areportID, but the asymmetry of using one of three identifiers for matching makes the dedup brittle either way.What changes do you think we should make in order to solve the problem?
Make the dedup match the same set of identifiers that the selection tagger already uses —
accountID,reportID, andlogin— instead of collapsing them to a single best-key string. Concretely, inParticipantSearchResults.tsx:selectedParticipantKeysSet with three Sets built from theparticipantsprop: one ofaccountIDs, one ofreportIDs (excluding the sentinel'-1'used for name-only attendees), and one oflogins..filter((option) => !selectedParticipantKeys.has(getParticipantOptionKey(option)))with a predicate that returns true only when none of the option'saccountID/reportID/loginappear in the corresponding Set. The predicate mirrors thedoOptionsMatchpolicy already used byuseSearchSelectorso the two stay in sync — same identifiers, same precedence.The
initiallySelectedReportIDpath (used when the participant is restored from Recents, where theparticipantsprop is the empty array) is left unchanged: its move-to-top / tagging logic already keys onreportID, which is the only identifier available in that case, so the new predicate would not change its behavior there.After this,
getParticipantOptionKeybecomes unused; it can be removed since no other caller depends on it.Verification: the scenario in the bug report (Create Expense → Next → pick a contact who also has a recent chat → tap "To" on the confirmation page) must show exactly one checkmark and exactly one row for the picked user, and the mirror scenario (pick from Recents, return) must continue to show one checkmark with the user pinned at the top of the list.
Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/90607-participant-dedup-identifiers?expand=1
What alternative solutions did you explore? (Optional)
A narrower fix is to teach
getParticipantOptionKeyto emit a composite key ("${reportID ?? ''}|${accountID ?? ''}|${login ?? ''}") and dedup on string equality of that composite. This is cheaper to write but leaves the dedup function still semantically "match one key out of many" — any future option shape that fills different identifier slots on each side reintroduces the same class of bug. Aligning the dedup withdoOptionsMatchis the change that closes the family of bugs, not just this instance.A second alternative is to set
shouldKeepSelectedInAvailableOptions: trueon theuseSearchSelectorcall so the recents/contacts row is allowed to remain but tagged correctly, and rely entirely on the SelectionList to render a single checkmark. That defers responsibility into shared selector state and changes behavior for paths that currently rely on the items being filtered out (the SelectionList still renders the duplicate row, just with one checkmark instead of two — visually misleading). Filtering at the participants-picker layer keeps the visible list correct.[-]Expense - Two different contacts are selected when trying to submit IOU.[/-][+][$250] Expense - Two different contacts are selected when trying to submit IOU.[/+]32 remaining items
melvin-bot commentedon Aug 6, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Aug 6, 2026
Payment Summary
Resolving PRs:
Upwork Job
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Aug 10, 2026
Payment Summary
Contributor: @lorretheboy paid $250 via Upwork
Contributor+: @thelullabyy due $250 via NewDot
@lorretheboy can you please accept the job below? Please reply here and tag me once you have.
https://www.upwork.com/jobs/~022078170244893095638
^ Test case created. Thx
lorretheboy commentedon Aug 10, 2026
@mallenexpensify I accepted the job. Thx
mallenexpensify commentedon Aug 11, 2026
@lorretheboy paid, summary updated above. Sorry for closing early by mistake
laurenreidexpensify commentedon Aug 17, 2026
$250 approved @thelullabyy