Skip to content

[Due for payment 2026-08-07] [$250] Expense - Two different contacts are selected when trying to submit IOU. #90607

Closed

Description

@mitarachim

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


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.

  1. Open the staging.new.expensify.com wesbite.
  2. Click on FAB > "Create Expense"
  3. Enter amount > "Next"
  4. Select any user from "Contacts" list
  5. On details page, click on "To" field again.
  6. Note that two different users were selected.
  7. Click on "Next"
  8. 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.

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

Bug7153231_1778692957501.Contacts.mp4

View all open jobs on GitHub

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

Activity

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

    @MelvinBot
    Contributor

    🚨 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 accountID OR reportID OR login, 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.

    Code references & verified evidence (SHA e4ae3a2)

    Two-checkmark asymmetry:

    NotFoundPage on Next:

    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:

    1. 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), or login matches the selected participant.
    2. 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.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jul 17, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jul 17, 2026
  5. melvin-bot commented on Jul 17, 2026

    @melvin-bot

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

  6. wildan-m commented on Jul 17, 2026

    @wildan-m
    Contributor

    🚨 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:

    const getParticipantOptionKey = (option: Partial<Participant>) => option.reportID ?? option.accountID?.toString() ?? option.login ?? option.phoneNumber ?? '';

    That filter is then applied to the Workspace, Recents, and Contacts sections:

    if ((availableOptions.workspaceChats ?? []).length > 0) {
    sections.push({
    title: translate('workspace.common.workspace'),
    data: (availableOptions.workspaceChats ?? []).filter((option) => !selectedParticipantKeys.has(getParticipantOptionKey(option))),
    sectionIndex: 1,
    });
    }
    if (availableOptions.selfDMChat) {
    sections.push({
    title: translate('workspace.invoices.paymentMethods.personal'),
    data: availableOptions.selfDMChat ? [availableOptions.selfDMChat] : [],
    sectionIndex: 2,
    });
    }
    if (!isWorkspacesOnly) {
    const shouldFilterRecentReportsToWorkspaceOnly = isPerDiemRequest || isTimeRequest;
    const recentReports = shouldFilterRecentReportsToWorkspaceOnly ? availableOptions.recentReports.filter((report) => report.isPolicyExpenseChat) : availableOptions.recentReports;
    if (recentReports.length > 0) {
    sections.push({
    title: translate('common.recents'),
    data: recentReports.filter((option) => !selectedParticipantKeys.has(getParticipantOptionKey(option))),
    sectionIndex: 3,
    });
    }
    if (availableOptions.personalDetails.length > 0 && !isPerDiemRequest && !isTimeRequest) {
    sections.push({
    title: translate('common.contacts'),
    data: availableOptions.personalDetails.filter((option) => !selectedParticipantKeys.has(getParticipantOptionKey(option))),
    sectionIndex: 4,
    });
    }

    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:

    const doOptionsMatch = (option1: OptionData, option2: OptionData) => {
    return (
    (option1.accountID && option1.accountID === option2.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison
    (option1.reportID && option1.reportID !== '-1' && option1.reportID === option2.reportID) ||
    (option1.login && option1.login === option2.login)
    );
    };

    const isOptionSelected = (option: OptionData) => selectedOptions.some((selected) => doOptionsMatch(selected, option));
    const searchOptions = {
    ...baseOptions,
    personalDetails: baseOptions.personalDetails.map((option) => ({
    ...option,
    isSelected: isOptionSelected(option),
    })),
    recentReports: baseOptions.recentReports.map((option) => ({
    ...option,
    isSelected: isOptionSelected(option),
    })),

    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:

    1. 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.
    2. 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.

    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 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.

  7. 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
  8. 32 remaining items

  9. added
    Awaiting PaymentAuto-added when associated PR is deployed to production
    and removed on Aug 2, 2026
  10. melvin-bot commented on Aug 6, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  11. melvin-bot commented on Aug 6, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    Upwork Job

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
  12. mallenexpensify commented on Aug 10, 2026

    @mallenexpensify
    Contributor

    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

  13. lorretheboy commented on Aug 10, 2026

    @lorretheboy
    Contributor

    @mallenexpensify I accepted the job. Thx

  14. mallenexpensify commented on Aug 11, 2026

    @mallenexpensify
    Contributor

    @lorretheboy paid, summary updated above. Sorry for closing early by mistake

  15. laurenreidexpensify commented on Aug 17, 2026

    @laurenreidexpensify
    Contributor

    $250 approved @thelullabyy

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

Metadata

Metadata

Labels

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

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions