Skip to content

Conversation

@marufsharifi
Copy link
Contributor

@marufsharifi marufsharifi commented Jan 23, 2026

Explanation of Change

This will fix the behavior of jumping the lists. When the user selects/deselects, it shouldn't jump. However, when it goes away and comes back, the selected items should be at the very top.

Fixed Issues

$ #69184
PROPOSAL: #69184 (comment)

Tests

  1. Create a workspace
  2. Go to Company cards
  3. Click Add Card
  4. Select/deselect a new Country
  5. The selected item should stay in its place, and the list shouldn't jump.
  • Verify that no errors appear in the JS console

Offline tests

Same as Tests

QA Steps

Same as Tests

// TODO: These must be filled out, or the issue title must include "[No QA]."

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

1. Workspace > Members > Invite new members.

1-v.mp4

2. Workspace > Workflows > Approvals > Approver > set Approver.

2-v.mp4

3. Workspace Chat > Chat Details > Members > Invite New members.

3-v.mp4

4. Workspace > Company Cards > Add Card

Screen.Recording.1404-10-28.at.6.15.28.PM.mov

5. Reports > Search Drop downs > From

Screen.Recording.1404-10-28.at.6.56.03.PM.mov

6. Profile > Timezones

Screen.Recording.1404-10-28.at.7.12.22.PM.mov

7. Reports > Filters > Workspace

Screen.Recording.1404-10-28.at.7.29.56.PM.mov

8. Reports > Filters > Purchase currency

Screen.Recording.1404-10-28.at.7.30.38.PM.mov

9. Reports > Filters > Currency

Screen.Recording.1404-10-28.at.7.32.24.PM.mov

10. Reports > Filters > Category

Screen.Recording.1404-10-28.at.7.37.30.PM.mov

11. Reports > Filters > To

Screen.Recording.1404-10-28.at.7.38.17.PM.mov

12. Workspace > Overview > Default Currency

Screen.Recording.1404-10-28.at.7.57.06.PM.mov

13. Expense details > Amount (tag value)

Screen.Recording.1404-10-28.at.8.07.01.PM.mov

14. Expense details > attendees.

Screen.Recording.1404-11-05.at.12.25.51.PM.mp4

15. Workspace > expensify card > state

Screen.Recording.1404-11-05.at.10.34.22.AM.mov

16. Workspace > workflows > payment account > State

Screen.Recording.1404-11-05.at.10.36.19.AM.mov

17. Profile > Address > Country

Screen.Recording.1404-11-05.at.10.50.14.AM.mov

18. Subscriptions > Add payment card > Currency

Screen.Recording.1404-11-05.at.10.52.08.AM.mov

19. Preferences > Payment Currency

Screen.Recording.1404-11-05.at.10.53.19.AM.mov

20. Reports > Search Drop downs > Workspace

Screen.Recording.1404-11-05.at.12.45.14.PM.mov

21. Wallet > Add Bank account

Screen.Recording.1404-11-05.at.2.10.50.PM.mov

22. Wallet > Add Bank account > Currency

Screen.Recording.1404-11-05.at.2.11.27.PM.mov

23. Profile > Address > State

Screen.Recording.1404-11-05.at.2.13.03.PM.mov

24. Group Chat > Details > members > invite new members

Screen.Recording.1404-11-05.at.1.03.11.PM.mp4

25. Workspace > Workflows > Submissions > Frequency > Monthly > Date of Month

Screen.Recording.1404-11-05.at.2.38.24.PM.mov

26. Preferences > Language

Screen.Recording.1404-11-05.at.3.25.28.PM.mov

@melvin-bot
Copy link

melvin-bot bot commented Jan 23, 2026

@linhvovan29546 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot melvin-bot bot requested review from trjExpensify and removed request for a team January 23, 2026 09:41
@melvin-bot
Copy link

melvin-bot bot commented Jan 23, 2026

Hey! I see that you made changes to our Form component. Make sure to update the docs in FORMS.md accordingly. Cheers!

@melvin-bot melvin-bot bot removed the request for review from a team January 23, 2026 09:41

newSections.push(formattedResults.section);
const selectedIDsSet = new Set(initialSelectedOptions.map((option) => option.accountID));
const unselectedFormattedSectionData = formattedResults.section.data.filter((option) => !selectedIDsSet.has(option.accountID));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

Inside the .filter() on line 157, there is a .some() call that iterates through initialSelectedOptions for every item in chatOptions.recentReports. This creates O(n*m) complexity where both operations could be optimized.

Suggested fix: Create a Set of initial selected account IDs before the filter operations to enable O(1) lookups:

const initialSelectedAccountIDs = new Set(initialSelectedOptions.map((option) => option.accountID));
const unselectedRecentReports = chatOptions.recentReports.filter((report) => !initialSelectedAccountIDs.has(report.accountID));

This pattern should also be applied to line 162 with chatOptions.personalDetails.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.


// If the current user is not selected, add them to the top of the list
if (!selectedCurrentUser && chatOptions.currentUserOption) {
if (!selectedCurrentUser && chatOptions.currentUserOption && !initialSelectedOptions.some((option) => option.accountID === chatOptions.currentUserOption?.accountID)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

The .some() call inside the conditional on line 145 iterates through initialSelectedOptions but doesn't use any iterator-dependent values from the conditional context. Since this is inside a conditional (not an array method callback), this should be optimized.

Suggested fix: Create a Set of account IDs before this conditional:

const initialSelectedAccountIDs = new Set(initialSelectedOptions.map((option) => option.accountID));

// Then use in conditional:
if (!selectedCurrentUser && chatOptions.currentUserOption && !initialSelectedAccountIDs.has(chatOptions.currentUserOption?.accountID)) {

This Set can be reused for the filter operations on lines 157 and 162 as well.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

let sortedSectionData = sectionData.sort((a, b) => localeCompare(a?.login?.toLowerCase() ?? '', b?.login?.toLowerCase() ?? ''));

if (initialSelectedOptions.length && cleanSearchTerm === '') {
sortedSectionData = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

Inside the .map() callback, the selectedOptions.some() call on line 177 doesn't use the iterator (participant) in its logic - it only checks if the participant.accountID matches any selected option. This function call should be hoisted outside the map.

Suggested fix: Create a Set of selected account IDs before the map:

const selectedAccountIDsSet = new Set(selectedOptions.map((option) => option.accountID));

const mappedParticipants = initialSelectedOptions.map((participant) => {
    const participantData = {
        ...participant,
        selected: selectedAccountIDsSet.has(participant.accountID),
    };
    // ... rest of logic
});

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

}
allMembers.push({
...formatMemberForList(selected),
isSelected: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

Inside the for loop on line 158, the allMembers.map() call creates a new Set on every iteration. This is an iterator-independent operation that should be hoisted outside the loop.

Suggested fix: Move the seenLogins Set creation before the loop:

const seenLogins = new Set(allMembers.map((member) => member.login));

for (const selected of selectedOptions) {
    if (selected.login && seenLogins.has(selected.login)) {
        continue;
    }
    // ... rest of logic
}

This eliminates O(n²) complexity where the map runs for every selected option.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

remainingItems.push(option);
}
}
combined.splice(0, combined.length, ...initialItems, ...remainingItems);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

Inside the for loop on line 256, the combined.map() call creates a new Set on every iteration. This is an iterator-independent operation that should be hoisted outside the loop.

Suggested fix: Move the seenLogins Set creation before the loop:

const seenLogins = new Set(combined.map((option) => option.login));

for (const option of selectedOptions) {
    if (option.login && seenLogins.has(option.login)) {
        continue;
    }
    // ... rest of logic
}

This avoids O(n²) complexity where the map executes for every selected option.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

title: undefined,
data: [availableOptions.userToInvite],
// Add any selected items not present in searchOptions (defensive)
const seenLogins = new Set(allMembers.map((member) => member.login));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

Inside the for loop on line 138, the allMembers.map() call creates a new Set on every iteration. This is an iterator-independent operation that should be hoisted outside the loop.

Suggested fix: Move the seenLogins Set creation before the loop:

const seenLogins = new Set(allMembers.map((member) => member.login));

for (const selected of selectedOptions) {
    if (selected.login && seenLogins.has(selected.login)) {
        continue;
    }
    // ... rest of logic
}

This eliminates O(n²) complexity where the map executes for every selected option.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

onSelectRow={saveSelectedTimezone}
textInputOptions={textInputOptions}
initiallyFocusedItemKey={timezoneOptions.find((tz) => tz.text === timezone.selected)?.keyForList}
initiallyFocusedItemKey={orderedTimezoneOptions.find((tz) => tz.text === timezone.selected)?.keyForList}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

The .find() call on line 106 is not technically a violation since it's outside of any iteration. However, the inline find could be optimized if performance is a concern by doing the find once and storing the result.

Actually, looking more carefully, this is NOT a PERF-13 violation because:

  • The .find() is not inside another array method callback
  • It's a standalone operation executed once during render
  • There's no iterator-independent function call pattern here

This is acceptable code and should NOT be flagged.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

}
}, [addNewCard?.data.selectedCountry, currentCountry, doesCountrySupportPlaid]);

useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-6 (docs)

The useEffect on line 75 updates state (currentCountry) based on a computed value (getCountry()). This pattern should be avoided - the value can be computed directly.

Suggested fix: Remove the useEffect and derive the value directly:

const currentCountry = useMemo(() => getCountry(), [getCountry]);
// Or simply:
const currentCountry = getCountry();

This ensures currentCountry is always synchronized with getCountry() without needing an effect, and eliminates an extra render cycle.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect is intentional:

currentCountry is user-controlled state (it changes when the user taps a country). We can’t derive it directly from getCountry() or we’d lose user selections and be unable to set it via onSelectionChange.
The effect’s job is to resync the default when upstream data (Onyx/route currency) changes; removing it would desync the UI from the new default.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 05b55479e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 236 to 240
for (const option of selectedOptions) {
if (option.login && seenLogins.has(option.login)) {
continue;
}
combined.push(option);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid adding non-matching selected attendees during search

This loop appends every selected attendee that isn’t already in the search results, regardless of whether the current search term matches. With a non-empty search term, this makes unrelated selected people appear in the results (previously formatSectionsFromSearchTerm only surfaced selected attendees when they matched the search). That’s a regression for the search UX and can hide relevant matches; consider filtering selectedOptions by the search term before pushing them when cleanSearchTerm is non-empty.

Useful? React with 👍 / 👎.

Comment on lines 139 to 143
for (const selected of selectedOptions) {
if (selected.login && seenLogins.has(selected.login)) {
continue;
}
allMembers.push({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t inject selected members into filtered invite results

This block unconditionally re-adds selected members that aren’t part of the current search results. When a user is typing a search term, it causes non-matching selections to show up in the filtered list (previously selectedOptionsForDisplay only showed selected entries that matched the term). That makes the invite search inconsistent and harder to use; consider only appending selected members when there is no active search term.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We compute isSearching once and only append selected members that aren’t in searchOptions when there’s no active search term. This prevents non-matching selected entries from appearing in filtered invite results while keeping the no-jump behavior intact.

@codecov
Copy link

codecov bot commented Jan 23, 2026

@trjExpensify trjExpensify requested a review from a team January 23, 2026 12:29
trjExpensify
trjExpensify previously approved these changes Jan 23, 2026
Copy link
Contributor

@trjExpensify trjExpensify left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed change with @Expensify/design team! Tagging 'em in though, if they want to check it out. 👍

Copy link
Contributor

@linhvovan29546 linhvovan29546 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marufsharifi I’ll continue the review after you address the feedback from the AI reviewer.


const searchResults = searchOptions(debouncedSearchValue, countries);
const searchResults = useMemo(() => {
return searchOptions(debouncedSearchValue, countries, initialCountryRef.current ? [initialCountryRef.current] : []);
Copy link
Contributor

@linhvovan29546 linhvovan29546 Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces an unnecessary for loop inside searchOptions that runs whenever countries or debouncedSearchValue changes. We can improve performance by moving the loop outside of searchOptions and handling it at the countries level instead. With this approach, the loop runs only once, improving performance. You also need to apply the same pattern in other places as well.

const allCountries = useMemo(() => {
       const excludedCountriesSet = new Set(CONST.PLAID_EXCLUDED_COUNTRIES)
       const allCountryKeys = Object.keys(CONST.ALL_COUNTRIES);
       
       // Single pass: filter and reorder simultaneously
       const selectedOptions: string[] = [];
       const unselectedOptions: string[] = [];
       
       for (const countryISO of allCountryKeys) {
           // Skip excluded countries
           if (excludedCountriesSet.has(countryISO)) {
               continue;
           }
           
           // Move initial country to top if it exists
           if (initialCountry && countryISO === initialCountry) {
               selectedOptions.push(countryISO);
           } else {
               unselectedOptions.push(countryISO);
           }
       }
       
       return [...selectedOptions, ...unselectedOptions];
   }, [initialCountry])
...

   ...
   const countries = useMemo(
           () =>
               allCountries
                   .map((countryISO) => {
                       const countryName = translate(`allCountries.${countryISO}` as TranslationPaths);
                       return {
                           value: countryISO,
                           keyForList: countryISO,
                           text: countryName,
                           isSelected: currentCountry === countryISO,
                           searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`),
                       };
                   }),
           [translate, currentCountry,allCountries],
       );

@shawnborton
Copy link
Contributor

Been a while for this one!

I also thought we wanted to make sure all selected items would be at the top of the list if they were already selected before opening up the list? Does that sound familiar? Pretty sure we had asked for that a while ago, but I'm not sure where we stand there.

@linhvovan29546
Copy link
Contributor

Pretty sure we had asked for that a while ago

The previous PR took a long time, and the contributor couldn’t find a working solution after the selection list refactor, so we’re reopening proposals.

@shawnborton
Copy link
Contributor

shawnborton commented Jan 23, 2026

Ah okay. Well let's keep that in mind - if you go to a list that already has items selected, those should already be at the top.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants