Problem
A user reported a duplicate attendee on one of their own expenses. In OldDot it renders as a bare avatar; in NewDot it shows the user "alongside themselves" — two attendee entries that resolve to the same accountID but carry different identifiers (one with email populated, one with empty email/displayName).
Example: transaction 8310454824587380428.
Root cause
Two compounding issues:
1. Attendee dedup is keyed on email (falling back to displayName), never on accountID
getFilteredRecentAttendees (src/libs/OptionsListUtils/index.ts) dedups with attendee.email || attendee.displayName, and cross-filters selected attendees as attendee.email ? email === attendee.email : displayName === attendee.displayName.
updateMoneyRequestAttendees (src/libs/actions/IOU/UpdateMoneyRequest.ts) only unionBys the recent attendees list (not the saved list), again on email || displayName.
accountID is never used as a dedup key anywhere.
Backend Auth (Transaction::validateAttendees in auth/lib/Transaction.cpp) strips accountID/login, persisting only email/displayName/avatarUrl, and does no per-attendee dedup (it only drops an attendee when both email and displayName are empty). So two entries for the same account with different identifiers both survive.
2. formatCurrentUserToAttendee can emit an attendee with accountID set but empty email/displayName
formatCurrentUserToAttendee (src/libs/IOUUtils.ts) is the default attendee seeded onto every new expense (wired in at MoneyRequest.ts:301). It always sets accountID: currentUser.accountID but falls everything else back to '':
const initialAttendee: Attendee = {
email: currentUser?.login ?? '',
login: currentUser?.login ?? '',
displayName: currentUser.displayName ?? '',
accountID: currentUser.accountID, // ← always set
...
};
If currentUserPersonalDetails has an accountID (always present) but login/displayName aren't hydrated yet, you get {accountID: <you>, email: '', displayName: ''}.
By contrast, getReportOwnerAsAttendee bails out (return undefined) when login and displayName are both empty, so it can't produce the empty entry. formatCurrentUserToAttendee has no such guard — it only checks !currentUser.
Caveat: Auth's validateAttendees filters out attendees with empty email and empty displayName, so an empty attendee persisting on the saved transaction means it reached storage via a path that didn't re-run that filter (likely the optimistic Onyx draft, or a later edit re-adding the current user).
Suggested fix direction
- Guard
formatCurrentUserToAttendee the same way getReportOwnerAsAttendee is guarded — don't emit an attendee when there's no login/displayName.
- Dedup by a stable identity that prefers accountID when present (e.g.
accountID ?? email ?? displayName) in getFilteredRecentAttendees and when assembling the saved attendee list, and/or normalize the owner attendee's email to the canonical primary login. Since Auth strips accountID, the App must dedup before save (or validateAttendees should be taught to dedup on email/displayName).
Reported in Slack.
Issue Owner
Current Issue Owner: @truph01
Problem
A user reported a duplicate attendee on one of their own expenses. In OldDot it renders as a bare avatar; in NewDot it shows the user "alongside themselves" — two attendee entries that resolve to the same accountID but carry different identifiers (one with email populated, one with empty
email/displayName).Example: transaction
8310454824587380428.Root cause
Two compounding issues:
1. Attendee dedup is keyed on
email(falling back todisplayName), never onaccountIDgetFilteredRecentAttendees(src/libs/OptionsListUtils/index.ts) dedups withattendee.email || attendee.displayName, and cross-filters selected attendees asattendee.email ? email === attendee.email : displayName === attendee.displayName.updateMoneyRequestAttendees(src/libs/actions/IOU/UpdateMoneyRequest.ts) onlyunionBys the recent attendees list (not the saved list), again onemail || displayName.accountIDis never used as a dedup key anywhere.Backend Auth (
Transaction::validateAttendeesinauth/lib/Transaction.cpp) stripsaccountID/login, persisting onlyemail/displayName/avatarUrl, and does no per-attendee dedup (it only drops an attendee when both email and displayName are empty). So two entries for the same account with different identifiers both survive.2.
formatCurrentUserToAttendeecan emit an attendee withaccountIDset but emptyemail/displayNameformatCurrentUserToAttendee(src/libs/IOUUtils.ts) is the default attendee seeded onto every new expense (wired in atMoneyRequest.ts:301). It always setsaccountID: currentUser.accountIDbut falls everything else back to'':If
currentUserPersonalDetailshas anaccountID(always present) butlogin/displayNamearen't hydrated yet, you get{accountID: <you>, email: '', displayName: ''}.By contrast,
getReportOwnerAsAttendeebails out (return undefined) when login and displayName are both empty, so it can't produce the empty entry.formatCurrentUserToAttendeehas no such guard — it only checks!currentUser.Caveat: Auth's
validateAttendeesfilters out attendees with empty email and empty displayName, so an empty attendee persisting on the saved transaction means it reached storage via a path that didn't re-run that filter (likely the optimistic Onyx draft, or a later edit re-adding the current user).Suggested fix direction
formatCurrentUserToAttendeethe same waygetReportOwnerAsAttendeeis guarded — don't emit an attendee when there's no login/displayName.accountID ?? email ?? displayName) ingetFilteredRecentAttendeesand when assembling the saved attendee list, and/or normalize the owner attendee's email to the canonical primary login. Since Auth stripsaccountID, the App must dedup before save (orvalidateAttendeesshould be taught to dedup on email/displayName).Reported in Slack.
Issue Owner
Current Issue Owner: @truph01