Stop scroll jump on search filter participant selection#92045
Stop scroll jump on search filter participant selection#92045MelvinBot wants to merge 35 commits into
Conversation
Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppandroid.movAndroid: mWeb ChromeiOS: HybridAppios.moviOS: mWeb SafariMacOS: Chrome / Safariweb.mov |
| chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== currentUserOption.accountID); | ||
| } | ||
|
|
||
| const formattedResults = formatSectionsFromSearchTerm( |
There was a problem hiding this comment.
@MelvinBot formatSectionsFromSearchTerm is no longer used. Is this safe?
There was a problem hiding this comment.
Yes, safe.
-
formatSectionsFromSearchTermis no longer called from this file, but it's still used elsewhere (e.g.SearchFiltersChatsSelector.tsx:111,InSelector.tsx:103,MoneyRequestAttendeeSelector.tsx:211,ParticipantSearchResults.tsx:271) and exported fromOptionsListUtils/index.ts:3341. We're only dropping the call from this component, not the function itself. -
Its previous role here (extracting selected items into a dedicated top section) is replaced by:
shouldKeepSelectedInAvailableOptions: true→ selected items stay in Recents / Contacts with theirisSelectedflag instead of being yanked into a separate section.shouldSeparateNonExistingSelectedOptions: true+ theselectedNonExistingOptionssection → covers the only previously-covered case where a selected item wouldn't appear in Recents/Contacts (name-only attendees).- The dedicated
currentUserOptionsection at the top covers the current-user-at-top case.
-
Same pattern was already applied in Stop scroll jump on room invite member selection #91387 (
RoomInvitePage) and Stop scroll jump on workspace invite member selection #91250 (DynamicWorkspaceInvitePage), so the swap is well-trodden.
| // Selected options not present in personalDetails / recentReports (e.g. name-only attendees | ||
| // for the attendee filter). These need their own section so they stay visible. The current | ||
| // user is excluded since they already have a dedicated section above. | ||
| const extraSelectedOptions = selectedNonExistingOptions.filter((option) => !option.accountID || option.accountID !== currentUserAccountID); | ||
| if (extraSelectedOptions.length > 0) { | ||
| newSections.push({ | ||
| title: '', | ||
| data: extraSelectedOptions, | ||
| sectionIndex: 1, | ||
| }); | ||
| } |
There was a problem hiding this comment.
@MelvinBot I think selectedNonExistingOptions won't exist as server side search is never performed on this page. Can you confirm? If so, we can completely remove this section.
There was a problem hiding this comment.
Can't fully confirm — selectedNonExistingOptions is not tied to server-side search; it's populated whenever a selected option's login is not present in availableOptions.personalDetails. See useSearchSelector/base.ts:429-434:
const selectedNonExistingOptions = shouldSeparateNonExistingSelectedOptions
? (() => {
const personalDetailLogins = new Set(filteredPersonalDetails.map((option) => option.login).filter(Boolean));
return selectedOptionsForDisplay.filter((option) => !personalDetailLogins.has(option.login));
})()
: [];This component is shared by From / To / Assignee / Attendee filters:
- For From / To / Assignee (
shouldAllowNameOnlyOptions: false): pre-selected options are looked up byaccountIDinpersonalDetailsand dropped if not found (SearchFiltersParticipantsSelector.tsx:254-266), soselectedNonExistingOptionswill be empty in practice. You're right for this case. - For Attendee (
shouldAllowNameOnlyOptions: true): name-only attendees haveaccountID = CONST.DEFAULT_NUMBER_IDand aren't inpersonalDetails(SearchFiltersParticipantsSelector.tsx:26-50,:230-251). They WILL show up inselectedNonExistingOptions, and without this section they'd disappear from the list when selected.
So this section is load-bearing for the Attendee filter and shouldn't be removed. If you'd prefer, we could gate it behind shouldAllowNameOnlyOptions to make the intent explicit — happy to push that if you want.
There was a problem hiding this comment.
@MelvinBot I confirmed there's no case of selectedNonExistingOptions in Attendees filter. Let's remove this section
There was a problem hiding this comment.
Done — removed the selectedNonExistingOptions section, the shouldSeparateNonExistingSelectedOptions: true config flag, and the now-unused getEmptyArray import in 67b72ec.
There was a problem hiding this comment.
Done in 2bd5dcb1. Added a local extraSelectedOptions section (login-keyed, mirroring the extraSelectedReports pattern from SearchFiltersChatsSelector.tsx:111-123) above the Recents / Contacts sections so name-only attendees stay visible when selected. No new dependency on the hook's selectedNonExistingOptions.
AI test results
| Test | Result |
|---|---|
prettier --check |
✅ Passed |
eslint (changed file) |
✅ Passed |
typecheck-tsgo |
✅ No new errors on changed file (pre-existing errors in MapView/utils.ts, ConvertGpsPointsTo2DArray.ts, useClearSelectedDomainMembersOnMoveComplete.ts, TryNewDotUtilsTest.ts are unrelated) |
react-compiler-compliance-check check-changed |
getMainBranchCommitHash failed on the Melvin sandbox (origin/ resolves to empty in this shallow-clone setup). Change is a small computation inside an existing useMemo; no new components or hooks introduced. |
jest tests/unit/useSearchSelectorTest.tsx / useAdvancedSearchFilters.test.ts |
Not re-run — hook and useAdvancedSearchFilters aren't touched by this change. |
Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
| sectionIndex: 1, | ||
| data: chatOptions.recentReports, | ||
| sectionIndex: 2, | ||
| }); | ||
|
|
||
| newSections.push({ | ||
| title: '', | ||
| data: chatOptions.personalDetails, | ||
| sectionIndex: 2, | ||
| sectionIndex: 3, |
There was a problem hiding this comment.
@MelvinBot keep original sectionIndex as section 1 was removed
Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
|
Done — kept the original The |
|
@MelvinBot let's also fix SearchFiltersChatsSelector as similar component |
Apply the same fix as SearchFiltersParticipantsSelector to the chats selector — keep selected items in their natural position with isSelected instead of moving them into a top section, and prevent auto-scroll on toggle via shouldUpdateFocusedIndex and shouldPreventAutoScrollOnSelect. Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
|
Applied the same fix pattern to
AI Tests
|
|
The failing test check is unrelated to this PR — it's a flaky/pre-existing failure on Failure: Why it's unrelated:
The other failing check ( Per MelvinBot policy I do not push fixes for unrelated/flaky failures — please re-run the failed Jest shard. |
Code ReviewOverviewThis PR stops the scroll jump and section reshuffle that happened when toggling rows in the Search advanced-filters participant and chat selectors. Instead of pulling selected rows into a dedicated "selected" section at the top (via What changed
Findings1. Potential regression for name-only attendees (Attendee filter) — medium 2. PR description out of sync with diff — low 3. 4. Missing Tests
Security / perf
SummaryThe simplification looks correct for the common path and the scroll-position fix is well-targeted. The main thing I'd want confirmed before merge is the name-only attendee case (finding #1) — if that path is broken, the fix needs a small extras-section equivalent like the Chats selector has. The PR description also needs to be brought in line with the current diff. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3a19cd57fb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| @@ -180,26 +145,14 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, | |||
| sectionIndex: 2, | |||
There was a problem hiding this comment.
Preserve selected participants outside visible options
When this selector opens with existing filters whose participants are not in the current availableOptions page/search result, the selected rows disappear from the list because the selected section was removed and the rendered data is now only chatOptions.recentReports plus chatOptions.personalDetails. shouldKeepSelectedInAvailableOptions only keeps selected items that getValidOptions already returned; it does not append selectedOptions initialized from initialAccountIDs. In that scenario the filter remains applied when pressing Apply, but the user cannot see or deselect it unless they know to search for that exact person, which regresses the previous selected section behavior.
Useful? React with 👍 / 👎.
@MelvinBot restore |
|
The failing checks are unrelated to this PR.
No code changes pushed — re-running the job should clear the flaky test failure. |
|
@MelvinBot when list no less than |
Restores the extraSelectedReports-style local pattern from SearchFiltersChatsSelector to keep selected items (notably name-only attendees in the Attendee filter) visible when they aren't present in the personalDetails / recentReports sections. Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
|
MelvinBot has made 5 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR. Next steps: A human should review the recent commits and resolve the conflicting CI errors manually. |
|
Before I push, want to confirm I have the intent right since this is a behavior change and there's been significant churn already. My interpretation:
Open questions before I push:
If those three match your intent, I'll implement and push. If any differ, lmk and I'll adjust before writing code. |
|
@MelvinBot do this |
- Capture pre-selected options into a frozen 'selected' section at the top on the first render with data when Recents + Contacts >= 12 items. - Selecting a row in the captured section keeps it in place; only its isSelected flag updates. - New selections stay in their natural Recents/Contacts position. - Captured items are filtered out of Recents/Contacts to avoid duplicates. - Apply the same pattern to SearchFiltersChatsSelector. Order: pre-selected -> extra-selected (non-existing) -> current user -> recents -> contacts. Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
|
Pushed in d6f84ac. Implemented all three confirmed answers:
Implementation notes for review:
Initial AI tests passed locally (prettier, lint on changed, react-compiler COMPILED for both files, |
|
@MelvinBot selected items should be filtered out from search. |
Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
|
Done in AI test results
|
|
@MelvinBot is it possible to extract common hook and utils for putting pre-selected items at the top on first render and then lock? |
|
@MelvinBot Simplify Explanation of Change, all code comments to be more human readable |
Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
|
Done — pushed Explanation of Change: rewrote to two short paragraphs covering the user-visible change + the shared hook, dropping the prop-by-prop list. Code comments: trimmed across Lint, typecheck, and react-compiler-compliance all green; no behavior change. |
@MelvinBot update tests steps like above and mark as ready for review |
|
@mkhutornyi 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] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5b430095ad
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (extraSelectedOptions.length > 0) { | ||
| baseSections.push({title: '', data: extraSelectedOptions, sectionIndex: 1}); |
There was a problem hiding this comment.
Mark extra selected name-only attendees as selected
When an Attendee filter contains a name-only value that is not present in the current recents/contacts (for example after a cache clear, which the hydration fallback explicitly supports), extraSelectedOptions is rendered directly in this new top section. Those hydrated name-only options only carry selected: true from getOptionDataFromAttendee/the fallback object, not isSelected, and UserSelectionListItem/SelectionListWithSections use isSelected for the checkbox, so the already-applied attendee appears unchecked and tapping it will actually remove it. The previous formatted selected section forced selected rows to isSelected: true; this path should do the same for extra selected rows.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not reproducible. Works fine:
Screen.Recording.2026-05-31.at.8.49.06.PM.mov
|
There's bug of showing current user twice in Attendee filter but also happens on staging so out of scope. Screen.Recording.2026-05-31.at.8.47.40.PM.mov |
|
There's critical bug - Screen.Recording.2026-05-31.at.8.54.23.PM.mov |
|
@mkhutornyi 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] |
| // `initialAccountIDs` holds accountIDs (or displayName / login for name-only attendees), but for any | ||
| // contact with a 1:1 DM, the default `keyForList` is the reportID. Use an explicit getKey so the hook | ||
| // matches on the right identifier. | ||
| const getKey = (option: OptionData) => { |
There was a problem hiding this comment.
❌ CONSISTENCY-3 (docs)
The getKey function (lines 156-165) duplicates the identifier-extraction logic that already exists inside applyChanges (lines 188-196). Both code paths resolve an OptionData to a string identifier using the same branching: check shouldAllowNameOnlyOptions, then check accountID vs DEFAULT_NUMBER_ID, then fall back to displayName || login.
Reuse getKey inside applyChanges to eliminate the duplication:
const applyChanges = () => {
const selectedIdentifiers = selectedOptions
.map(getKey)
.filter(Boolean) as string[];
onFiltersUpdate(selectedIdentifiers);
Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute());
};Reviewed at: 5b43009 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
There was a problem hiding this comment.
Good catch — done in 22d1ef810f. applyChanges now calls getKey instead of inlining the same branching.
Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
|
@mkhutornyi 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] |
mkhutornyi
left a comment
There was a problem hiding this comment.
@marcaaron all yours since @francoisl is on parental leave
Explanation of Change
Stop scrolling and jumping when selecting items in the Search advanced filters participants selector (Spend > Filters > From / To / Attendee / Assignee).
Previously, selecting an item caused it to move into a dedicated "selected" section at the top of the list, and the list also auto-scrolled to the top. This was disorienting when selecting multiple participants from a long list.
Changes:
shouldKeepSelectedInAvailableOptions: trueandshouldSeparateNonExistingSelectedOptions: truetouseSearchSelectorso selected items remain inside the Recents / Contacts sections (with theirisSelectedflag) instead of being moved out into a separate "selected" section.selectedNonExistingOptionsfor an optional top section, which keeps selected items that are not present in the result list visible — for example, name-only attendees in the Attendee filter.formatSectionsFromSearchTermcall that previously pulled all selected items out into a top section.currentUserOptionat the top (filtered out of Recents / Contacts) withisSelectedreflecting whether the current user is selected, so the current user's row also stays in place across selections.shouldUpdateFocusedIndexandshouldPreventAutoScrollOnSelecttoSelectionListWithSectionsto keep the user's scroll position when a row is selected.This follows the same pattern applied in #91387 (
RoomInvitePage) and #91250 (DynamicWorkspaceInvitePage).Fixed Issues
$ #61414
PROPOSAL:
Tests
Offline tests
Same as Tests
QA Steps
Same as Tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
AI Tests
prettierlint(on changed file)typecheck-tsgosrc/components/MapView/utils.tsandsrc/libs/migrations/ConvertGpsPointsTo2DArray.ts, unrelated to this PR)react-compiler-compliance-checkjest tests/unit/useSearchSelectorTest.tsxjest tests/unit/hooks/useAdvancedSearchFilters.test.ts