From 85e40e55a01ce0ff8b336024820c71a4cafb99cd Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 29 May 2026 06:45:03 +0000 Subject: [PATCH 01/35] Stop scroll jump on search filter participant selection Co-authored-by: mkhutornyi --- .../SearchFiltersParticipantsSelector.tsx | 171 ++++++++---------- 1 file changed, 75 insertions(+), 96 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 7486fd797887..30d8c9a9ef0c 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -5,12 +5,10 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import useSearchSelector from '@hooks/useSearchSelector'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {formatSectionsFromSearchTerm, getFilteredRecentAttendees, getParticipantsOption} from '@libs/OptionsListUtils'; +import {getFilteredRecentAttendees, getParticipantsOption} from '@libs/OptionsListUtils'; import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -19,6 +17,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Attendee} from '@src/types/onyx/IOU'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; /** @@ -63,13 +62,10 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); - const reportAttributesDerived = useReportAttributes(); - const privateIsArchivedMap = usePrivateIsArchivedMap(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) const recentAttendeeLists = useMemo( @@ -77,19 +73,31 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, [personalDetails, recentAttendees, currentUserEmail, currentUserAccountID, shouldAllowNameOnlyOptions], ); - const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleSelection, areOptionsInitialized, onListEndReached} = - useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - includeUserToInvite: true, - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - includeRecentReports: true, - shouldInitialize: didScreenTransitionEnd, - includeCurrentUser: true, - recentAttendees: recentAttendeeLists, - shouldAllowNameOnlyOptions, - }); + const { + searchTerm, + debouncedSearchTerm, + setSearchTerm, + availableOptions, + selectedOptions, + selectedNonExistingOptions = getEmptyArray(), + setSelectedOptions, + toggleSelection, + areOptionsInitialized, + onListEndReached, + } = useSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + includeUserToInvite: true, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + includeRecentReports: true, + shouldInitialize: didScreenTransitionEnd, + includeCurrentUser: true, + recentAttendees: recentAttendeeLists, + shouldAllowNameOnlyOptions, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: true, + }); const {sections, headerMessage} = useMemo(() => { const newSections = []; @@ -100,106 +108,75 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const chatOptions = {...availableOptions}; const currentUserOption = chatOptions.currentUserOption; - // Ensure current user is not in personalDetails when they should be excluded - if (currentUserOption) { + // Ensure current user is not in personalDetails or recentReports to avoid duplication + // with the dedicated currentUserOption section shown at the top of the list. + if (currentUserOption?.accountID) { chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID); + chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== currentUserOption.accountID); } - const formattedResults = formatSectionsFromSearchTerm( - debouncedSearchTerm.trim().toLowerCase(), - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - const selectedCurrentUser = formattedResults.section.data.find((option) => option.accountID === currentUserAccountID); - - // If the current user is already selected, remove them from the recent reports and personal details - if (selectedCurrentUser) { - chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== selectedCurrentUser.accountID); - chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== selectedCurrentUser.accountID); - } - - // If the current user is not selected, add them to the top of the list - // Falls back to creating from personal details to handle pagination edge cases - if (!selectedCurrentUser) { - let currentUserOptionToShow = chatOptions.currentUserOption; - const currentUserDetails = currentUserAccountID ? personalDetails?.[currentUserAccountID] : undefined; - if (!currentUserOptionToShow && currentUserAccountID && currentUserDetails) { - const candidateOption = getParticipantsOption(currentUserDetails, personalDetails) as OptionData; - const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); - if (!trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(candidateOption, currentUserAccountID, trimmedSearchTerm)) { - currentUserOptionToShow = candidateOption; - } - } - - if (currentUserOptionToShow) { - const formattedName = getDisplayNameForParticipant({ - accountID: currentUserOptionToShow.accountID, - shouldAddCurrentUserPostfix: true, - personalDetailsData: personalDetails, - formatPhoneNumber, - }); - currentUserOptionToShow.text = formattedName; - - newSections.push({ - title: '', - data: [currentUserOptionToShow], - sectionIndex: 0, - }); + const isCurrentUserSelected = !!currentUserAccountID && selectedOptions.some((option) => option.accountID === currentUserAccountID); + + // Show the current user at the top of the list. Falls back to creating from personal details + // to handle pagination edge cases. + let currentUserOptionToShow = currentUserOption; + const currentUserDetails = currentUserAccountID ? personalDetails?.[currentUserAccountID] : undefined; + if (!currentUserOptionToShow && currentUserAccountID && currentUserDetails) { + const candidateOption = getParticipantsOption(currentUserDetails, personalDetails) as OptionData; + const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); + if (!trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(candidateOption, currentUserAccountID, trimmedSearchTerm)) { + currentUserOptionToShow = candidateOption; } } - newSections.push({ - ...formattedResults.section, - data: formattedResults.section.data.map((option) => ({...option, isSelected: true})) as OptionData[], - }); + if (currentUserOptionToShow) { + const formattedName = getDisplayNameForParticipant({ + accountID: currentUserOptionToShow.accountID, + shouldAddCurrentUserPostfix: true, + personalDetailsData: personalDetails, + formatPhoneNumber, + }); + currentUserOptionToShow = {...currentUserOptionToShow, text: formattedName, isSelected: isCurrentUserSelected}; + + newSections.push({ + title: '', + data: [currentUserOptionToShow], + sectionIndex: 0, + }); + } - // Filter current user from recentReports to avoid duplicate with currentUserOption section - // Only filter if both the report and currentUserOption have valid accountIDs to avoid - // accidentally filtering out name-only attendees (which have accountID: undefined) - const filteredRecentReports = chatOptions.recentReports.filter( - (report) => !report.accountID || !chatOptions.currentUserOption?.accountID || report.accountID !== chatOptions.currentUserOption.accountID, - ); + // 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, + }); + } newSections.push({ title: '', - data: filteredRecentReports, - sectionIndex: 1, + data: chatOptions.recentReports, + sectionIndex: 2, }); newSections.push({ title: '', data: chatOptions.personalDetails, - sectionIndex: 2, + sectionIndex: 3, }); - const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption; + const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !currentUserOptionToShow && extraSelectedOptions.length === 0; const message = noResultsFound ? translate('common.noResultsFound') : undefined; return { sections: newSections, headerMessage: message, }; - }, [ - areOptionsInitialized, - availableOptions, - debouncedSearchTerm, - selectedOptions, - privateIsArchivedMap, - currentUserAccountID, - personalDetails, - allPolicies, - reportAttributesDerived, - translate, - formatPhoneNumber, - ]); + }, [areOptionsInitialized, availableOptions, debouncedSearchTerm, selectedOptions, selectedNonExistingOptions, currentUserAccountID, personalDetails, translate, formatPhoneNumber]); const resetChanges = useCallback(() => { setSelectedOptions([]); @@ -331,6 +308,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldShowTextInput footerContent={footerContent} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + shouldUpdateFocusedIndex + shouldPreventAutoScrollOnSelect onSelectRow={handleParticipantSelection} isLoadingNewOptions={isLoadingNewOptions} shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} From 67b72ec3f4d74fdcc58323869c20f1223b555b32 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 29 May 2026 18:00:05 +0000 Subject: [PATCH 02/35] Remove unused selectedNonExistingOptions section Co-authored-by: mkhutornyi --- .../SearchFiltersParticipantsSelector.tsx | 56 ++++++------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 30d8c9a9ef0c..44972b95efcc 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -17,7 +17,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Attendee} from '@src/types/onyx/IOU'; -import getEmptyArray from '@src/types/utils/getEmptyArray'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; /** @@ -73,31 +72,20 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, [personalDetails, recentAttendees, currentUserEmail, currentUserAccountID, shouldAllowNameOnlyOptions], ); - const { - searchTerm, - debouncedSearchTerm, - setSearchTerm, - availableOptions, - selectedOptions, - selectedNonExistingOptions = getEmptyArray(), - setSelectedOptions, - toggleSelection, - areOptionsInitialized, - onListEndReached, - } = useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - includeUserToInvite: true, - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - includeRecentReports: true, - shouldInitialize: didScreenTransitionEnd, - includeCurrentUser: true, - recentAttendees: recentAttendeeLists, - shouldAllowNameOnlyOptions, - shouldKeepSelectedInAvailableOptions: true, - shouldSeparateNonExistingSelectedOptions: true, - }); + const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleSelection, areOptionsInitialized, onListEndReached} = + useSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + includeUserToInvite: true, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + includeRecentReports: true, + shouldInitialize: didScreenTransitionEnd, + includeCurrentUser: true, + recentAttendees: recentAttendeeLists, + shouldAllowNameOnlyOptions, + shouldKeepSelectedInAvailableOptions: true, + }); const {sections, headerMessage} = useMemo(() => { const newSections = []; @@ -145,18 +133,6 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, }); } - // 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, - }); - } - newSections.push({ title: '', data: chatOptions.recentReports, @@ -169,14 +145,14 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, sectionIndex: 3, }); - const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !currentUserOptionToShow && extraSelectedOptions.length === 0; + const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !currentUserOptionToShow; const message = noResultsFound ? translate('common.noResultsFound') : undefined; return { sections: newSections, headerMessage: message, }; - }, [areOptionsInitialized, availableOptions, debouncedSearchTerm, selectedOptions, selectedNonExistingOptions, currentUserAccountID, personalDetails, translate, formatPhoneNumber]); + }, [areOptionsInitialized, availableOptions, debouncedSearchTerm, selectedOptions, currentUserAccountID, personalDetails, translate, formatPhoneNumber]); const resetChanges = useCallback(() => { setSelectedOptions([]); From 2d755e8cdd46a05b28fdc788300757336eeea933 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 29 May 2026 18:53:24 +0000 Subject: [PATCH 03/35] Fix: keep original sectionIndex after removing selected section Co-authored-by: mkhutornyi --- src/components/Search/SearchFiltersParticipantsSelector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 44972b95efcc..d5e17f9aa2ac 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -136,13 +136,13 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, newSections.push({ title: '', data: chatOptions.recentReports, - sectionIndex: 2, + sectionIndex: 1, }); newSections.push({ title: '', data: chatOptions.personalDetails, - sectionIndex: 3, + sectionIndex: 2, }); const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !currentUserOptionToShow; From 3a19cd57fbdd74bf28c510eab4e355b800c61625 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 29 May 2026 19:05:16 +0000 Subject: [PATCH 04/35] Fix: stop scroll jump on chats filter selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Search/SearchFiltersChatsSelector.tsx | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index f3dd7035b2b1..d9e0da9683d8 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -14,7 +14,7 @@ import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionS import useSortedActions from '@hooks/useSortedActions'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; +import {createOptionFromReport, filterAndOrderOptions, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; import type {Option} from '@libs/OptionsListUtils'; import type {OptionWithKey, SelectionListSections} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; @@ -108,32 +108,29 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const sections: SelectionListSections = []; if (!isLoading) { - const formattedResults = formatSectionsFromSearchTerm( - cleanSearchTerm, - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - personalDetails, - false, - undefined, - reportAttributesDerived, - ); - - sections.push(formattedResults.section); - - const visibleReportsWhenSearchTermNonEmpty = chatOptions.recentReports.map((report) => (selectedReportIDs.includes(report.reportID) ? getSelectedOptionData(report) : report)); - const visibleReportsWhenSearchTermEmpty = chatOptions.recentReports.filter((report) => !selectedReportIDs.includes(report.reportID)); - const reportsFiltered = cleanSearchTerm === '' ? visibleReportsWhenSearchTermEmpty : visibleReportsWhenSearchTermNonEmpty; + const selectedReportIDsSet = new Set(selectedReportIDs); + const visibleReportIDsSet = new Set(chatOptions.recentReports.map((report) => report.reportID)); + + // Selected reports that aren't in the current filtered results (e.g., they no longer match + // the search term after server-side search ran). Show them in a dedicated section so they + // remain visible. + const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID)); + if (extraSelectedReports.length > 0) { + sections.push({ + data: extraSelectedReports, + sectionIndex: 0, + }); + } + // Keep selected reports in their natural position in the list (marked isSelected) rather than + // moving them into a top section, so the user's scroll position isn't disrupted on toggle. + const visibleReports = chatOptions.recentReports.map((report) => (selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report)); sections.push({ - data: reportsFiltered, + data: visibleReports, sectionIndex: 1, }); } - const noResultsFound = didScreenTransitionEnd && sections.at(0)?.data.length === 0 && sections.at(1)?.data.length === 0; + const noResultsFound = didScreenTransitionEnd && sections.every((section) => section.data.length === 0); const headerMessage = noResultsFound ? translate('common.noResultsFound') : undefined; useEffect(() => { @@ -191,6 +188,8 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen footerContent={footerContent} canSelectMultiple shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + shouldUpdateFocusedIndex + shouldPreventAutoScrollOnSelect textInputOptions={textInputOptions} isLoadingNewOptions={isLoadingNewOptions} shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} From 2bd5dcb1676fabd2af71637a890328cf15abc9b7 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 29 May 2026 19:43:51 +0000 Subject: [PATCH 05/35] Add section for selected options not visible in Recents / Contacts 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 --- .../SearchFiltersParticipantsSelector.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index d5e17f9aa2ac..39891b6c5b0d 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -133,19 +133,32 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, }); } + // Selected options that aren't in the visible Recents / Contacts sections (e.g. name-only + // attendees for the attendee filter). Show them in a dedicated section so they remain visible + // when selected. The current user is excluded since they already have a dedicated section above. + const visibleLogins = new Set([...chatOptions.personalDetails.map((detail) => detail.login), ...chatOptions.recentReports.map((report) => report.login)].filter(Boolean)); + const extraSelectedOptions = selectedOptions.filter((option) => option.accountID !== currentUserAccountID && !visibleLogins.has(option.login)); + if (extraSelectedOptions.length > 0) { + newSections.push({ + title: '', + data: extraSelectedOptions, + sectionIndex: 1, + }); + } + newSections.push({ title: '', data: chatOptions.recentReports, - sectionIndex: 1, + sectionIndex: 2, }); newSections.push({ title: '', data: chatOptions.personalDetails, - sectionIndex: 2, + sectionIndex: 3, }); - const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !currentUserOptionToShow; + const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !currentUserOptionToShow && extraSelectedOptions.length === 0; const message = noResultsFound ? translate('common.noResultsFound') : undefined; return { From d6f84ac3b440231d060855e9383fa3f6cf0c77d1 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 29 May 2026 20:08:14 +0000 Subject: [PATCH 06/35] Pin pre-selected items at top in long lists - 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 --- .../Search/SearchFiltersChatsSelector.tsx | 43 ++++++-- .../SearchFiltersParticipantsSelector.tsx | 97 ++++++++++++++----- 2 files changed, 108 insertions(+), 32 deletions(-) diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index d9e0da9683d8..21441057c5d0 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -1,5 +1,5 @@ import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -105,29 +105,56 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }); + // Frozen snapshot of pre-selected reports captured on the first render where options are + // initialized and the Recents list is long enough to warrant pinning pre-selected items at + // the top so they're not lost in a long list. `null` means we haven't captured yet; an empty + // array means we evaluated and chose not to pin. Captured during render (per React's "set + // state during render" pattern) so the snapshot is taken at the same render where data first + // becomes available. + const [frozenSelectedReports, setFrozenSelectedReports] = useState(null); + if (frozenSelectedReports === null && !isLoading) { + setFrozenSelectedReports(chatOptions.recentReports.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? selectedOptions : []); + } + + const frozenReportIDsSet = useMemo(() => new Set((frozenSelectedReports ?? []).map((report) => report.reportID)), [frozenSelectedReports]); + const sections: SelectionListSections = []; if (!isLoading) { const selectedReportIDsSet = new Set(selectedReportIDs); const visibleReportIDsSet = new Set(chatOptions.recentReports.map((report) => report.reportID)); - // Selected reports that aren't in the current filtered results (e.g., they no longer match - // the search term after server-side search ran). Show them in a dedicated section so they - // remain visible. - const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID)); + // Frozen pre-selected reports pinned at the top. They keep their order from capture; their + // `isSelected` flag tracks the current selection so toggling them in place updates the + // checkmark without moving the row. + const frozenReports = (frozenSelectedReports ?? []).map((report) => (selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : {...report, isSelected: false})); + if (frozenReports.length > 0) { + sections.push({ + data: frozenReports, + sectionIndex: 0, + }); + } + + // Selected reports that aren't pinned in the frozen section and aren't in the current + // filtered results (e.g., they no longer match the search term after server-side search + // ran). Show them in a dedicated section so they remain visible. + const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && !frozenReportIDsSet.has(report.reportID)); if (extraSelectedReports.length > 0) { sections.push({ data: extraSelectedReports, - sectionIndex: 0, + sectionIndex: 1, }); } // Keep selected reports in their natural position in the list (marked isSelected) rather than // moving them into a top section, so the user's scroll position isn't disrupted on toggle. - const visibleReports = chatOptions.recentReports.map((report) => (selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report)); + // Filter out frozen items to avoid duplication with the top section. + const visibleReports = chatOptions.recentReports + .filter((report) => !frozenReportIDsSet.has(report.reportID)) + .map((report) => (selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report)); sections.push({ data: visibleReports, - sectionIndex: 1, + sectionIndex: 2, }); } const noResultsFound = didScreenTransitionEnd && sections.every((section) => section.data.length === 0); diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 39891b6c5b0d..ded84ae7b804 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -87,6 +87,23 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldKeepSelectedInAvailableOptions: true, }); + // Frozen snapshot of pre-selected options captured on the first render where options are + // initialized and the combined Recents + Contacts list is long enough to warrant pinning + // pre-selected items at the top so they're not lost in a long list. `null` means we haven't + // captured yet; an empty array means we evaluated and chose not to pin. Captured during + // render (per React's "set state during render" pattern) so the snapshot is taken at the + // same render where data first becomes available. + const [frozenSelectedOptions, setFrozenSelectedOptions] = useState(null); + if ( + frozenSelectedOptions === null && + areOptionsInitialized && + // Wait until pre-selected options have been hydrated from initialAccountIDs before capturing. + (initialAccountIDs.length === 0 || selectedOptions.length > 0) + ) { + const totalVisible = availableOptions.recentReports.length + availableOptions.personalDetails.length; + setFrozenSelectedOptions(totalVisible >= CONST.STANDARD_LIST_ITEM_LIMIT ? selectedOptions : []); + } + const {sections, headerMessage} = useMemo(() => { const newSections = []; if (!areOptionsInitialized) { @@ -97,16 +114,55 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const currentUserOption = chatOptions.currentUserOption; // Ensure current user is not in personalDetails or recentReports to avoid duplication - // with the dedicated currentUserOption section shown at the top of the list. + // with the dedicated currentUserOption section shown below. if (currentUserOption?.accountID) { chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID); chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== currentUserOption.accountID); } + // Build the frozen "pre-selected" top section. Items keep their original position; their + // `isSelected` flag tracks the current selection so toggling them in place updates the + // checkmark without moving the row. + const frozenAccountIDs = new Set((frozenSelectedOptions ?? []).map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID)); + const frozenLogins = new Set((frozenSelectedOptions ?? []).map((option) => option.login).filter((login): login is string => !!login)); + const isOptionFrozen = (option: Pick) => + (!!option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && frozenAccountIDs.has(option.accountID)) || (!!option.login && frozenLogins.has(option.login)); + + const frozenSection = (frozenSelectedOptions ?? []).map((option) => { + const isSelected = selectedOptions.some( + (selected) => + (!!selected.accountID && selected.accountID !== CONST.DEFAULT_NUMBER_ID && selected.accountID === option.accountID) || + (!!selected.login && selected.login === option.login), + ); + return {...option, isSelected}; + }); + if (frozenSection.length > 0) { + newSections.push({ + title: '', + data: frozenSection, + sectionIndex: 0, + }); + } + + // Selected options that aren't in the frozen section, current user section, or visible + // Recents / Contacts sections (e.g. name-only attendees for the attendee filter). Show + // them in a dedicated section so they remain visible when selected. + const visibleLogins = new Set([...chatOptions.personalDetails.map((detail) => detail.login), ...chatOptions.recentReports.map((report) => report.login)].filter(Boolean)); + const extraSelectedOptions = selectedOptions.filter((option) => option.accountID !== currentUserAccountID && !visibleLogins.has(option.login) && !isOptionFrozen(option)); + if (extraSelectedOptions.length > 0) { + newSections.push({ + title: '', + data: extraSelectedOptions, + sectionIndex: 1, + }); + } + const isCurrentUserSelected = !!currentUserAccountID && selectedOptions.some((option) => option.accountID === currentUserAccountID); - // Show the current user at the top of the list. Falls back to creating from personal details - // to handle pagination edge cases. + // Show the current user below the frozen and extra-selected sections, but above + // Recents / Contacts. Falls back to creating from personal details to handle pagination + // edge cases. The current user is skipped here if they were already pinned in the + // frozen section above. let currentUserOptionToShow = currentUserOption; const currentUserDetails = currentUserAccountID ? personalDetails?.[currentUserAccountID] : undefined; if (!currentUserOptionToShow && currentUserAccountID && currentUserDetails) { @@ -117,7 +173,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, } } - if (currentUserOptionToShow) { + if (currentUserOptionToShow && !isOptionFrozen(currentUserOptionToShow)) { const formattedName = getDisplayNameForParticipant({ accountID: currentUserOptionToShow.accountID, shouldAddCurrentUserPostfix: true, @@ -129,43 +185,36 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, newSections.push({ title: '', data: [currentUserOptionToShow], - sectionIndex: 0, + sectionIndex: 2, }); + } else { + currentUserOptionToShow = undefined; } - // Selected options that aren't in the visible Recents / Contacts sections (e.g. name-only - // attendees for the attendee filter). Show them in a dedicated section so they remain visible - // when selected. The current user is excluded since they already have a dedicated section above. - const visibleLogins = new Set([...chatOptions.personalDetails.map((detail) => detail.login), ...chatOptions.recentReports.map((report) => report.login)].filter(Boolean)); - const extraSelectedOptions = selectedOptions.filter((option) => option.accountID !== currentUserAccountID && !visibleLogins.has(option.login)); - if (extraSelectedOptions.length > 0) { - newSections.push({ - title: '', - data: extraSelectedOptions, - sectionIndex: 1, - }); - } + // Filter frozen items out of Recents and Contacts to avoid duplication with the top section. + const recentReports = frozenSection.length > 0 ? chatOptions.recentReports.filter((report) => !isOptionFrozen(report)) : chatOptions.recentReports; + const contacts = frozenSection.length > 0 ? chatOptions.personalDetails.filter((detail) => !isOptionFrozen(detail)) : chatOptions.personalDetails; newSections.push({ title: '', - data: chatOptions.recentReports, - sectionIndex: 2, + data: recentReports, + sectionIndex: 3, }); newSections.push({ title: '', - data: chatOptions.personalDetails, - sectionIndex: 3, + data: contacts, + sectionIndex: 4, }); - const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !currentUserOptionToShow && extraSelectedOptions.length === 0; + const noResultsFound = contacts.length === 0 && recentReports.length === 0 && !currentUserOptionToShow && extraSelectedOptions.length === 0 && frozenSection.length === 0; const message = noResultsFound ? translate('common.noResultsFound') : undefined; return { sections: newSections, headerMessage: message, }; - }, [areOptionsInitialized, availableOptions, debouncedSearchTerm, selectedOptions, currentUserAccountID, personalDetails, translate, formatPhoneNumber]); + }, [areOptionsInitialized, availableOptions, debouncedSearchTerm, selectedOptions, frozenSelectedOptions, currentUserAccountID, personalDetails, translate, formatPhoneNumber]); const resetChanges = useCallback(() => { setSelectedOptions([]); From 633c81abe6c0e503b9c393d57363e4ad4350a57d Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 29 May 2026 20:28:32 +0000 Subject: [PATCH 07/35] Filter pre-selected snapshot by search term Co-authored-by: mkhutornyi --- .../SearchFiltersParticipantsSelector.tsx | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index ded84ae7b804..8aaeb6327a85 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -122,20 +122,27 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, // Build the frozen "pre-selected" top section. Items keep their original position; their // `isSelected` flag tracks the current selection so toggling them in place updates the - // checkmark without moving the row. + // checkmark without moving the row. The set used to dedupe Recents / Contacts is built + // from the unfiltered snapshot so rows hidden by the current search term still don't + // duplicate into the lists below. const frozenAccountIDs = new Set((frozenSelectedOptions ?? []).map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID)); const frozenLogins = new Set((frozenSelectedOptions ?? []).map((option) => option.login).filter((login): login is string => !!login)); const isOptionFrozen = (option: Pick) => (!!option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && frozenAccountIDs.has(option.accountID)) || (!!option.login && frozenLogins.has(option.login)); - const frozenSection = (frozenSelectedOptions ?? []).map((option) => { - const isSelected = selectedOptions.some( - (selected) => - (!!selected.accountID && selected.accountID !== CONST.DEFAULT_NUMBER_ID && selected.accountID === option.accountID) || - (!!selected.login && selected.login === option.login), - ); - return {...option, isSelected}; - }); + const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); + const matchesSearchTerm = (option: OptionData) => !trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(option, option.accountID ?? CONST.DEFAULT_NUMBER_ID, trimmedSearchTerm); + + const frozenSection = (frozenSelectedOptions ?? []) + .filter((option) => matchesSearchTerm(option)) + .map((option) => { + const isSelected = selectedOptions.some( + (selected) => + (!!selected.accountID && selected.accountID !== CONST.DEFAULT_NUMBER_ID && selected.accountID === option.accountID) || + (!!selected.login && selected.login === option.login), + ); + return {...option, isSelected}; + }); if (frozenSection.length > 0) { newSections.push({ title: '', @@ -146,9 +153,13 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, // Selected options that aren't in the frozen section, current user section, or visible // Recents / Contacts sections (e.g. name-only attendees for the attendee filter). Show - // them in a dedicated section so they remain visible when selected. + // them in a dedicated section so they remain visible when selected. Filtered by the + // current search term so they don't appear when the user types something that doesn't + // match. const visibleLogins = new Set([...chatOptions.personalDetails.map((detail) => detail.login), ...chatOptions.recentReports.map((report) => report.login)].filter(Boolean)); - const extraSelectedOptions = selectedOptions.filter((option) => option.accountID !== currentUserAccountID && !visibleLogins.has(option.login) && !isOptionFrozen(option)); + const extraSelectedOptions = selectedOptions.filter( + (option) => option.accountID !== currentUserAccountID && !visibleLogins.has(option.login) && !isOptionFrozen(option) && matchesSearchTerm(option), + ); if (extraSelectedOptions.length > 0) { newSections.push({ title: '', @@ -167,7 +178,6 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const currentUserDetails = currentUserAccountID ? personalDetails?.[currentUserAccountID] : undefined; if (!currentUserOptionToShow && currentUserAccountID && currentUserDetails) { const candidateOption = getParticipantsOption(currentUserDetails, personalDetails) as OptionData; - const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); if (!trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(candidateOption, currentUserAccountID, trimmedSearchTerm)) { currentUserOptionToShow = candidateOption; } From 8eb5725eb08cb7fd969c096873096f76c53ee14a Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 03:44:09 +0000 Subject: [PATCH 08/35] Extract useFrozenPreSelection hook and section helpers Pulls the duplicated frozen-snapshot capture, identity Set, and Recents / Contacts dedupe logic out of SearchFiltersChatsSelector and SearchFiltersParticipantsSelector into: - src/hooks/useFrozenPreSelection.ts: captures the immutable snapshot via set-state-during-render, returns the snapshot plus an isFrozen predicate. Supports compound identity via getKeys. - src/libs/SelectionListOrderUtils.ts: buildFrozenSection syncs isSelected against current selection; excludeFrozenItems dedupes Recents / Contacts. Both call sites are refactored to consume the new hook. Behavior is preserved. Co-authored-by: mkhutornyi --- .../Search/SearchFiltersChatsSelector.tsx | 32 ++++---- .../SearchFiltersParticipantsSelector.tsx | 75 +++++++++---------- src/hooks/useFrozenPreSelection.ts | 75 +++++++++++++++++++ src/libs/SelectionListOrderUtils.ts | 18 +++++ 4 files changed, 143 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useFrozenPreSelection.ts diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index 21441057c5d0..ebb8de4259fb 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -1,11 +1,12 @@ import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useFilteredOptions from '@hooks/useFilteredOptions'; +import useFrozenPreSelection from '@hooks/useFrozenPreSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -18,6 +19,7 @@ import {createOptionFromReport, filterAndOrderOptions, getAlternateText, getSear import type {Option} from '@libs/OptionsListUtils'; import type {OptionWithKey, SelectionListSections} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; +import {buildFrozenSection, excludeFrozenItems} from '@libs/SelectionListOrderUtils'; import Navigation from '@navigation/Navigation'; import {searchInServer} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -105,18 +107,12 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }); - // Frozen snapshot of pre-selected reports captured on the first render where options are - // initialized and the Recents list is long enough to warrant pinning pre-selected items at - // the top so they're not lost in a long list. `null` means we haven't captured yet; an empty - // array means we evaluated and chose not to pin. Captured during render (per React's "set - // state during render" pattern) so the snapshot is taken at the same render where data first - // becomes available. - const [frozenSelectedReports, setFrozenSelectedReports] = useState(null); - if (frozenSelectedReports === null && !isLoading) { - setFrozenSelectedReports(chatOptions.recentReports.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? selectedOptions : []); - } - - const frozenReportIDsSet = useMemo(() => new Set((frozenSelectedReports ?? []).map((report) => report.reportID)), [frozenSelectedReports]); + const {frozen: frozenSelectedReports, isFrozen: isReportFrozen} = useFrozenPreSelection({ + selectedOptions, + isReady: !isLoading, + visibleCount: chatOptions.recentReports.length, + getKeys: (option) => [option.reportID], + }); const sections: SelectionListSections = []; @@ -127,7 +123,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen // Frozen pre-selected reports pinned at the top. They keep their order from capture; their // `isSelected` flag tracks the current selection so toggling them in place updates the // checkmark without moving the row. - const frozenReports = (frozenSelectedReports ?? []).map((report) => (selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : {...report, isSelected: false})); + const frozenReports = buildFrozenSection(frozenSelectedReports, (report) => selectedReportIDsSet.has(report.reportID)); if (frozenReports.length > 0) { sections.push({ data: frozenReports, @@ -138,7 +134,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen // Selected reports that aren't pinned in the frozen section and aren't in the current // filtered results (e.g., they no longer match the search term after server-side search // ran). Show them in a dedicated section so they remain visible. - const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && !frozenReportIDsSet.has(report.reportID)); + const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && !isReportFrozen(report)); if (extraSelectedReports.length > 0) { sections.push({ data: extraSelectedReports, @@ -149,9 +145,9 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen // Keep selected reports in their natural position in the list (marked isSelected) rather than // moving them into a top section, so the user's scroll position isn't disrupted on toggle. // Filter out frozen items to avoid duplication with the top section. - const visibleReports = chatOptions.recentReports - .filter((report) => !frozenReportIDsSet.has(report.reportID)) - .map((report) => (selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report)); + const visibleReports = excludeFrozenItems(chatOptions.recentReports, isReportFrozen).map((report) => + selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report, + ); sections.push({ data: visibleReports, sectionIndex: 2, diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 8aaeb6327a85..d9b7f6c1af6b 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -1,8 +1,9 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFrozenPreSelection from '@hooks/useFrozenPreSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; @@ -12,6 +13,7 @@ import {getFilteredRecentAttendees, getParticipantsOption} from '@libs/OptionsLi import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; +import {buildFrozenSection, excludeFrozenItems} from '@libs/SelectionListOrderUtils'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -87,22 +89,14 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldKeepSelectedInAvailableOptions: true, }); - // Frozen snapshot of pre-selected options captured on the first render where options are - // initialized and the combined Recents + Contacts list is long enough to warrant pinning - // pre-selected items at the top so they're not lost in a long list. `null` means we haven't - // captured yet; an empty array means we evaluated and chose not to pin. Captured during - // render (per React's "set state during render" pattern) so the snapshot is taken at the - // same render where data first becomes available. - const [frozenSelectedOptions, setFrozenSelectedOptions] = useState(null); - if ( - frozenSelectedOptions === null && - areOptionsInitialized && + const {frozen: frozenSelectedOptions, isFrozen: isOptionFrozen} = useFrozenPreSelection({ + selectedOptions, + isReady: areOptionsInitialized, // Wait until pre-selected options have been hydrated from initialAccountIDs before capturing. - (initialAccountIDs.length === 0 || selectedOptions.length > 0) - ) { - const totalVisible = availableOptions.recentReports.length + availableOptions.personalDetails.length; - setFrozenSelectedOptions(totalVisible >= CONST.STANDARD_LIST_ITEM_LIMIT ? selectedOptions : []); - } + canCapture: initialAccountIDs.length === 0 || selectedOptions.length > 0, + visibleCount: availableOptions.recentReports.length + availableOptions.personalDetails.length, + getKeys: (option) => [option.accountID, option.login], + }); const {sections, headerMessage} = useMemo(() => { const newSections = []; @@ -120,29 +114,21 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== currentUserOption.accountID); } - // Build the frozen "pre-selected" top section. Items keep their original position; their - // `isSelected` flag tracks the current selection so toggling them in place updates the - // checkmark without moving the row. The set used to dedupe Recents / Contacts is built - // from the unfiltered snapshot so rows hidden by the current search term still don't - // duplicate into the lists below. - const frozenAccountIDs = new Set((frozenSelectedOptions ?? []).map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID)); - const frozenLogins = new Set((frozenSelectedOptions ?? []).map((option) => option.login).filter((login): login is string => !!login)); - const isOptionFrozen = (option: Pick) => - (!!option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && frozenAccountIDs.has(option.accountID)) || (!!option.login && frozenLogins.has(option.login)); - const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); const matchesSearchTerm = (option: OptionData) => !trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(option, option.accountID ?? CONST.DEFAULT_NUMBER_ID, trimmedSearchTerm); - const frozenSection = (frozenSelectedOptions ?? []) - .filter((option) => matchesSearchTerm(option)) - .map((option) => { - const isSelected = selectedOptions.some( - (selected) => - (!!selected.accountID && selected.accountID !== CONST.DEFAULT_NUMBER_ID && selected.accountID === option.accountID) || - (!!selected.login && selected.login === option.login), - ); - return {...option, isSelected}; - }); + // Build the frozen "pre-selected" top section. Items keep their original position; their + // `isSelected` flag tracks the current selection so toggling them in place updates the + // checkmark without moving the row. Rows hidden by the current search term are filtered + // out of the visible section but stay in the snapshot so they still dedupe Recents / + // Contacts below. + const isOptionCurrentlySelected = (option: OptionData) => + selectedOptions.some( + (selected) => + (!!selected.accountID && selected.accountID !== CONST.DEFAULT_NUMBER_ID && selected.accountID === option.accountID) || + (!!selected.login && selected.login === option.login), + ); + const frozenSection = buildFrozenSection(frozenSelectedOptions.filter(matchesSearchTerm), isOptionCurrentlySelected); if (frozenSection.length > 0) { newSections.push({ title: '', @@ -202,8 +188,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, } // Filter frozen items out of Recents and Contacts to avoid duplication with the top section. - const recentReports = frozenSection.length > 0 ? chatOptions.recentReports.filter((report) => !isOptionFrozen(report)) : chatOptions.recentReports; - const contacts = frozenSection.length > 0 ? chatOptions.personalDetails.filter((detail) => !isOptionFrozen(detail)) : chatOptions.personalDetails; + const recentReports = frozenSection.length > 0 ? excludeFrozenItems(chatOptions.recentReports, isOptionFrozen) : chatOptions.recentReports; + const contacts = frozenSection.length > 0 ? excludeFrozenItems(chatOptions.personalDetails, isOptionFrozen) : chatOptions.personalDetails; newSections.push({ title: '', @@ -224,7 +210,18 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, sections: newSections, headerMessage: message, }; - }, [areOptionsInitialized, availableOptions, debouncedSearchTerm, selectedOptions, frozenSelectedOptions, currentUserAccountID, personalDetails, translate, formatPhoneNumber]); + }, [ + areOptionsInitialized, + availableOptions, + debouncedSearchTerm, + selectedOptions, + frozenSelectedOptions, + isOptionFrozen, + currentUserAccountID, + personalDetails, + translate, + formatPhoneNumber, + ]); const resetChanges = useCallback(() => { setSelectedOptions([]); diff --git a/src/hooks/useFrozenPreSelection.ts b/src/hooks/useFrozenPreSelection.ts new file mode 100644 index 000000000000..9dc0a1388789 --- /dev/null +++ b/src/hooks/useFrozenPreSelection.ts @@ -0,0 +1,75 @@ +import {useMemo, useState} from 'react'; +import CONST from '@src/CONST'; + +type UseFrozenPreSelectionOptions = { + /** Currently selected items. Captured as the frozen snapshot on the first ready render. */ + selectedOptions: T[]; + + /** Whether the underlying options are initialized and the snapshot can be evaluated. */ + isReady: boolean; + + /** Total number of items visible in the section list used to decide whether pinning is needed. */ + visibleCount: number; + + /** + * Extra gate to delay capture until pre-selected options have been hydrated (e.g. wait for + * `selectedOptions` to populate from `initialAccountIDs`). Defaults to true. + */ + canCapture?: boolean; + + /** Minimum visible-count required before pinning kicks in. Defaults to STANDARD_LIST_ITEM_LIMIT. */ + threshold?: number; + + /** Returns identity keys for an option (e.g. `[reportID]`, or `[accountID, login]`). */ + getKeys: (option: T) => Array; +}; + +function isValidKey(key: string | number | undefined | null): key is string | number { + return key !== undefined && key !== null && key !== '' && key !== CONST.DEFAULT_NUMBER_ID; +} + +/** + * Captures an immutable snapshot of the pre-selected items on the first render where data is + * available and the visible list is long enough to warrant pinning. Returns the snapshot and a + * predicate to dedupe rows elsewhere in the list. The snapshot is taken via React's "set state + * during render" pattern so callers can use it on the same render where data first becomes + * available — no extra renders are introduced. + */ +function useFrozenPreSelection({selectedOptions, isReady, visibleCount, canCapture = true, threshold = CONST.STANDARD_LIST_ITEM_LIMIT, getKeys}: UseFrozenPreSelectionOptions): { + frozen: T[]; + isFrozen: (option: T) => boolean; +} { + // `null` means we haven't captured yet; an empty array means we evaluated and chose not to pin. + const [frozen, setFrozen] = useState(null); + if (frozen === null && isReady && canCapture) { + setFrozen(visibleCount >= threshold ? selectedOptions : []); + } + + const frozenList = frozen ?? []; + + const frozenKeys = useMemo(() => { + const keys = new Set(); + for (const option of frozenList) { + for (const key of getKeys(option)) { + if (!isValidKey(key)) { + continue; + } + keys.add(key); + } + } + return keys; + }, [frozenList, getKeys]); + + const isFrozen = (option: T) => { + for (const key of getKeys(option)) { + if (isValidKey(key) && frozenKeys.has(key)) { + return true; + } + } + return false; + }; + + return {frozen: frozenList, isFrozen}; +} + +export default useFrozenPreSelection; diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index ec0e2ee261ec..b65d470cbbf4 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -21,4 +21,22 @@ function moveInitialSelectionToTop(items: T[], initia return [...selected, ...remaining]; } +/** + * Rebuilds a frozen pre-selection section by syncing each item's `isSelected` flag against the + * current selection, leaving row positions untouched. Pair with `useFrozenPreSelection` to render + * a stable top section whose checkmarks update on toggle without rows jumping. + */ +function buildFrozenSection(frozen: T[], isCurrentlySelected: (item: T) => boolean): T[] { + return frozen.map((item) => ({...item, isSelected: isCurrentlySelected(item)})); +} + +/** + * Excludes items that already appear in the frozen pre-selection section so they don't render + * twice when reused in Recents / Contacts sections below. + */ +function excludeFrozenItems(items: T[], isFrozen: (item: T) => boolean): T[] { + return items.filter((item) => !isFrozen(item)); +} + +export {buildFrozenSection, excludeFrozenItems}; export default moveInitialSelectionToTop; From 93973db9afb46ac4d1ff10b21abfde73bccc5de5 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 03:59:26 +0000 Subject: [PATCH 09/35] Generalize excludeFrozenItems jsdoc Co-authored-by: mkhutornyi --- src/libs/SelectionListOrderUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index b65d470cbbf4..aafd167d9a2b 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -31,8 +31,7 @@ function buildFrozenSection(frozen: T[], isCur } /** - * Excludes items that already appear in the frozen pre-selection section so they don't render - * twice when reused in Recents / Contacts sections below. + * Excludes items that already appear in the frozen pre-selection section so they don't render twice. */ function excludeFrozenItems(items: T[], isFrozen: (item: T) => boolean): T[] { return items.filter((item) => !isFrozen(item)); From 06b1f45c06d9b89497bfc8df1ce8b7638e7eab6e Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 04:23:14 +0000 Subject: [PATCH 10/35] Simplify comments to be more human readable Co-authored-by: mkhutornyi --- .../Search/SearchFiltersChatsSelector.tsx | 12 +++------ .../SearchFiltersParticipantsSelector.tsx | 26 ++++++------------- src/hooks/useFrozenPreSelection.ts | 26 ++++++++----------- src/libs/SelectionListOrderUtils.ts | 9 +++---- 4 files changed, 25 insertions(+), 48 deletions(-) diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index ebb8de4259fb..f110cb4bd95a 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -120,9 +120,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const selectedReportIDsSet = new Set(selectedReportIDs); const visibleReportIDsSet = new Set(chatOptions.recentReports.map((report) => report.reportID)); - // Frozen pre-selected reports pinned at the top. They keep their order from capture; their - // `isSelected` flag tracks the current selection so toggling them in place updates the - // checkmark without moving the row. + // Pre-selected reports pinned at the top. Row order is frozen; the checkmark updates on toggle. const frozenReports = buildFrozenSection(frozenSelectedReports, (report) => selectedReportIDsSet.has(report.reportID)); if (frozenReports.length > 0) { sections.push({ @@ -131,9 +129,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen }); } - // Selected reports that aren't pinned in the frozen section and aren't in the current - // filtered results (e.g., they no longer match the search term after server-side search - // ran). Show them in a dedicated section so they remain visible. + // Selected reports that don't show up anywhere else (e.g. dropped out of the current results). Surface them so they stay visible. const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && !isReportFrozen(report)); if (extraSelectedReports.length > 0) { sections.push({ @@ -142,9 +138,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen }); } - // Keep selected reports in their natural position in the list (marked isSelected) rather than - // moving them into a top section, so the user's scroll position isn't disrupted on toggle. - // Filter out frozen items to avoid duplication with the top section. + // The rest of Recents in their natural position. Selected rows just get the checkmark — moving them would jump the scroll. const visibleReports = excludeFrozenItems(chatOptions.recentReports, isReportFrozen).map((report) => selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report, ); diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index d9b7f6c1af6b..9dd835e4b1a6 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -92,7 +92,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const {frozen: frozenSelectedOptions, isFrozen: isOptionFrozen} = useFrozenPreSelection({ selectedOptions, isReady: areOptionsInitialized, - // Wait until pre-selected options have been hydrated from initialAccountIDs before capturing. + // Don't capture until pre-selected options have hydrated from initialAccountIDs. canCapture: initialAccountIDs.length === 0 || selectedOptions.length > 0, visibleCount: availableOptions.recentReports.length + availableOptions.personalDetails.length, getKeys: (option) => [option.accountID, option.login], @@ -107,8 +107,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const chatOptions = {...availableOptions}; const currentUserOption = chatOptions.currentUserOption; - // Ensure current user is not in personalDetails or recentReports to avoid duplication - // with the dedicated currentUserOption section shown below. + // Drop the current user from Recents / Contacts; we render them in their own section below. if (currentUserOption?.accountID) { chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID); chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== currentUserOption.accountID); @@ -117,11 +116,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); const matchesSearchTerm = (option: OptionData) => !trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(option, option.accountID ?? CONST.DEFAULT_NUMBER_ID, trimmedSearchTerm); - // Build the frozen "pre-selected" top section. Items keep their original position; their - // `isSelected` flag tracks the current selection so toggling them in place updates the - // checkmark without moving the row. Rows hidden by the current search term are filtered - // out of the visible section but stay in the snapshot so they still dedupe Recents / - // Contacts below. + // Pre-selected items pinned at the top. Row order is frozen; the checkmark updates on toggle. + // Search-filtered rows are hidden from view but stay in the snapshot so they still dedupe Recents / Contacts below. const isOptionCurrentlySelected = (option: OptionData) => selectedOptions.some( (selected) => @@ -137,11 +133,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, }); } - // Selected options that aren't in the frozen section, current user section, or visible - // Recents / Contacts sections (e.g. name-only attendees for the attendee filter). Show - // them in a dedicated section so they remain visible when selected. Filtered by the - // current search term so they don't appear when the user types something that doesn't - // match. + // Selected items that don't show up anywhere else (e.g. name-only attendees). Surface them so they stay visible, but still respect the search term. const visibleLogins = new Set([...chatOptions.personalDetails.map((detail) => detail.login), ...chatOptions.recentReports.map((report) => report.login)].filter(Boolean)); const extraSelectedOptions = selectedOptions.filter( (option) => option.accountID !== currentUserAccountID && !visibleLogins.has(option.login) && !isOptionFrozen(option) && matchesSearchTerm(option), @@ -156,10 +148,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const isCurrentUserSelected = !!currentUserAccountID && selectedOptions.some((option) => option.accountID === currentUserAccountID); - // Show the current user below the frozen and extra-selected sections, but above - // Recents / Contacts. Falls back to creating from personal details to handle pagination - // edge cases. The current user is skipped here if they were already pinned in the - // frozen section above. + // Current user goes above Recents / Contacts. Fall back to personalDetails if pagination dropped them, + // and skip if they're already pinned in the frozen section above. let currentUserOptionToShow = currentUserOption; const currentUserDetails = currentUserAccountID ? personalDetails?.[currentUserAccountID] : undefined; if (!currentUserOptionToShow && currentUserAccountID && currentUserDetails) { @@ -187,7 +177,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, currentUserOptionToShow = undefined; } - // Filter frozen items out of Recents and Contacts to avoid duplication with the top section. + // Avoid showing frozen items twice in Recents and Contacts. const recentReports = frozenSection.length > 0 ? excludeFrozenItems(chatOptions.recentReports, isOptionFrozen) : chatOptions.recentReports; const contacts = frozenSection.length > 0 ? excludeFrozenItems(chatOptions.personalDetails, isOptionFrozen) : chatOptions.personalDetails; diff --git a/src/hooks/useFrozenPreSelection.ts b/src/hooks/useFrozenPreSelection.ts index 9dc0a1388789..967727a29a04 100644 --- a/src/hooks/useFrozenPreSelection.ts +++ b/src/hooks/useFrozenPreSelection.ts @@ -2,25 +2,22 @@ import {useMemo, useState} from 'react'; import CONST from '@src/CONST'; type UseFrozenPreSelectionOptions = { - /** Currently selected items. Captured as the frozen snapshot on the first ready render. */ + /** Items to snapshot on the first ready render. */ selectedOptions: T[]; - /** Whether the underlying options are initialized and the snapshot can be evaluated. */ + /** True once the underlying list has loaded and we can take the snapshot. */ isReady: boolean; - /** Total number of items visible in the section list used to decide whether pinning is needed. */ + /** How many items the list shows. Pinning only kicks in once this passes the threshold. */ visibleCount: number; - /** - * Extra gate to delay capture until pre-selected options have been hydrated (e.g. wait for - * `selectedOptions` to populate from `initialAccountIDs`). Defaults to true. - */ + /** Hold off capturing until the selection is hydrated. Defaults to true. */ canCapture?: boolean; - /** Minimum visible-count required before pinning kicks in. Defaults to STANDARD_LIST_ITEM_LIMIT. */ + /** Skip pinning unless the list has at least this many items. Defaults to STANDARD_LIST_ITEM_LIMIT. */ threshold?: number; - /** Returns identity keys for an option (e.g. `[reportID]`, or `[accountID, login]`). */ + /** Identity keys for an item, e.g. `[reportID]` or `[accountID, login]`. */ getKeys: (option: T) => Array; }; @@ -29,17 +26,16 @@ function isValidKey(key: string | number | undefined | null): key is string | nu } /** - * Captures an immutable snapshot of the pre-selected items on the first render where data is - * available and the visible list is long enough to warrant pinning. Returns the snapshot and a - * predicate to dedupe rows elsewhere in the list. The snapshot is taken via React's "set state - * during render" pattern so callers can use it on the same render where data first becomes - * available — no extra renders are introduced. + * Pins the items that were pre-selected on first load to the top of a long list, so they don't + * get lost when the user starts toggling things. Returns the frozen snapshot and an `isFrozen` + * predicate for deduping rows elsewhere. Captures during render so callers can use it on the + * same render where data first becomes available — no extra renders. */ function useFrozenPreSelection({selectedOptions, isReady, visibleCount, canCapture = true, threshold = CONST.STANDARD_LIST_ITEM_LIMIT, getKeys}: UseFrozenPreSelectionOptions): { frozen: T[]; isFrozen: (option: T) => boolean; } { - // `null` means we haven't captured yet; an empty array means we evaluated and chose not to pin. + // null = not captured yet; [] = captured but list was too short to pin. const [frozen, setFrozen] = useState(null); if (frozen === null && isReady && canCapture) { setFrozen(visibleCount >= threshold ? selectedOptions : []); diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index aafd167d9a2b..72a6c642cf1c 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -22,17 +22,14 @@ function moveInitialSelectionToTop(items: T[], initia } /** - * Rebuilds a frozen pre-selection section by syncing each item's `isSelected` flag against the - * current selection, leaving row positions untouched. Pair with `useFrozenPreSelection` to render - * a stable top section whose checkmarks update on toggle without rows jumping. + * Refreshes the `isSelected` flag on frozen rows without changing their order. Pair with + * `useFrozenPreSelection` so checkmarks track the live selection while rows stay put. */ function buildFrozenSection(frozen: T[], isCurrentlySelected: (item: T) => boolean): T[] { return frozen.map((item) => ({...item, isSelected: isCurrentlySelected(item)})); } -/** - * Excludes items that already appear in the frozen pre-selection section so they don't render twice. - */ +/** Drops items already shown in the frozen top section so they don't render twice. */ function excludeFrozenItems(items: T[], isFrozen: (item: T) => boolean): T[] { return items.filter((item) => !isFrozen(item)); } From 60ebfe6282287249abb80e37ea4add0a7f79681e Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 04:47:50 +0000 Subject: [PATCH 11/35] Replace 'checkmarks' with 'selection indicators' to fix spell check Co-authored-by: mkhutornyi --- src/libs/SelectionListOrderUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index 72a6c642cf1c..7a5954566207 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -23,7 +23,7 @@ function moveInitialSelectionToTop(items: T[], initia /** * Refreshes the `isSelected` flag on frozen rows without changing their order. Pair with - * `useFrozenPreSelection` so checkmarks track the live selection while rows stay put. + * `useFrozenPreSelection` so selection indicators track the live selection while rows stay put. */ function buildFrozenSection(frozen: T[], isCurrentlySelected: (item: T) => boolean): T[] { return frozen.map((item) => ({...item, isSelected: isCurrentlySelected(item)})); From 4c7e67997f3c384bdc375e944b2b566c16f1329c Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 05:02:26 +0000 Subject: [PATCH 12/35] Drop useMemo from useFrozenPreSelection in favor of React Compiler Co-authored-by: mkhutornyi --- src/hooks/useFrozenPreSelection.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/hooks/useFrozenPreSelection.ts b/src/hooks/useFrozenPreSelection.ts index 967727a29a04..c91b1c342fbb 100644 --- a/src/hooks/useFrozenPreSelection.ts +++ b/src/hooks/useFrozenPreSelection.ts @@ -1,4 +1,4 @@ -import {useMemo, useState} from 'react'; +import {useState} from 'react'; import CONST from '@src/CONST'; type UseFrozenPreSelectionOptions = { @@ -43,18 +43,15 @@ function useFrozenPreSelection({selectedOptions, isReady, visibleCount, canCa const frozenList = frozen ?? []; - const frozenKeys = useMemo(() => { - const keys = new Set(); - for (const option of frozenList) { - for (const key of getKeys(option)) { - if (!isValidKey(key)) { - continue; - } - keys.add(key); + const frozenKeys = new Set(); + for (const option of frozenList) { + for (const key of getKeys(option)) { + if (!isValidKey(key)) { + continue; } + frozenKeys.add(key); } - return keys; - }, [frozenList, getKeys]); + } const isFrozen = (option: T) => { for (const key of getKeys(option)) { From f1bd0179247c577f9804b68ac6e69ddc5c68abb1 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 05:24:13 +0000 Subject: [PATCH 13/35] Filter chats selector frozen/extra sections by search term + base threshold on unfiltered count - Apply matchesSearchTerm to both frozenReports and extraSelectedReports so search hides non-matching rows (matches participants selector behavior and pre-PR formatSectionsFromSearchTerm contract). - Use defaultOptions.recentReports.length (unfiltered) for the useFrozenPreSelection threshold so a search term active at first ready render doesn't permanently disable pinning for the open cycle. Co-authored-by: mkhutornyi --- src/components/Search/SearchFiltersChatsSelector.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index f110cb4bd95a..6deeb022eed1 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -110,7 +110,8 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const {frozen: frozenSelectedReports, isFrozen: isReportFrozen} = useFrozenPreSelection({ selectedOptions, isReady: !isLoading, - visibleCount: chatOptions.recentReports.length, + // Threshold is based on the unfiltered list so a search term active at first ready render doesn't permanently disable pinning. + visibleCount: defaultOptions.recentReports.length, getKeys: (option) => [option.reportID], }); @@ -119,9 +120,12 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen if (!isLoading) { const selectedReportIDsSet = new Set(selectedReportIDs); const visibleReportIDsSet = new Set(chatOptions.recentReports.map((report) => report.reportID)); + const matchesSearchTerm = (report: OptionData) => + cleanSearchTerm === '' || !!report.text?.toLowerCase().includes(cleanSearchTerm) || !!report.login?.toLowerCase().includes(cleanSearchTerm); // Pre-selected reports pinned at the top. Row order is frozen; the checkmark updates on toggle. - const frozenReports = buildFrozenSection(frozenSelectedReports, (report) => selectedReportIDsSet.has(report.reportID)); + // Search-filtered rows are hidden from view but stay in the snapshot so they still dedupe Recents below. + const frozenReports = buildFrozenSection(frozenSelectedReports.filter(matchesSearchTerm), (report) => selectedReportIDsSet.has(report.reportID)); if (frozenReports.length > 0) { sections.push({ data: frozenReports, @@ -129,8 +133,8 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen }); } - // Selected reports that don't show up anywhere else (e.g. dropped out of the current results). Surface them so they stay visible. - const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && !isReportFrozen(report)); + // Selected reports that don't show up anywhere else (e.g. dropped out of the current results). Surface them so they stay visible, but still respect the search term. + const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && !isReportFrozen(report) && matchesSearchTerm(report)); if (extraSelectedReports.length > 0) { sections.push({ data: extraSelectedReports, From 09854943175125825b43a72c9acd4fd9228603ee Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 05:58:12 +0000 Subject: [PATCH 14/35] Address 8 review findings: noResultsFound flash, stale hydration, threshold, matcher, isFrozen stability, canCapture symmetry, keyForList invariant, live frozen rows Co-authored-by: mkhutornyi --- .../Search/SearchFiltersChatsSelector.tsx | 35 +++++++++++---- .../SearchFiltersParticipantsSelector.tsx | 15 +++++-- src/hooks/useFrozenPreSelection.ts | 43 +++++++++++-------- src/libs/SelectionListOrderUtils.ts | 4 ++ 4 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index 6deeb022eed1..e2f8f18997ab 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -15,9 +15,9 @@ import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionS import useSortedActions from '@hooks/useSortedActions'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {createOptionFromReport, filterAndOrderOptions, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; +import {createOptionFromReport, filterAndOrderOptions, filterReports, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; import type {Option} from '@libs/OptionsListUtils'; -import type {OptionWithKey, SelectionListSections} from '@libs/OptionsListUtils/types'; +import type {OptionWithKey, SearchOptionData, SelectionListSections} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; import {buildFrozenSection, excludeFrozenItems} from '@libs/SelectionListOrderUtils'; import Navigation from '@navigation/Navigation'; @@ -73,7 +73,10 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); const sortedActions = useSortedActions(); - const selectedOptions: OptionData[] = selectedReportIDs.map((id) => { + // Build an OptionData for a reportID from current Onyx state. Used for both the live selectedOptions + // list and the frozen section, so frozen rows pick up text / alternateText updates as Onyx hydrates + // rather than rendering the captured-at-snapshot-time values forever. + const buildOptionFromReportID = (id: string): OptionData => { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`]; const reportData = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`]; const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportData?.policyID}`]; @@ -82,7 +85,9 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; const alternateText = getAlternateText(report, {}, {isReportArchived: privateIsArchived, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID}); return {...report, alternateText}; - }); + }; + + const selectedOptions: OptionData[] = selectedReportIDs.map(buildOptionFromReportID); const defaultOptions = isLoading || !isScreenTransitionEnd || !listOptions @@ -110,22 +115,36 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const {frozen: frozenSelectedReports, isFrozen: isReportFrozen} = useFrozenPreSelection({ selectedOptions, isReady: !isLoading, + // Mirrors the participants selector — don't snapshot before the pre-selection has hydrated. + // Safe today (selectedReportIDs is seeded from useState(initialReportIDs)), but explicit so a + // future async initialReportIDs source doesn't silently snapshot an empty list. + canCapture: initialReportIDs.length === 0 || selectedReportIDs.length > 0, // Threshold is based on the unfiltered list so a search term active at first ready render doesn't permanently disable pinning. visibleCount: defaultOptions.recentReports.length, getKeys: (option) => [option.reportID], }); + // Rebuild frozen rows from current Onyx state each render. The snapshot only fixes which reports + // are pinned and their order; the display values stay live so a row captured before reports / + // personalDetails hydrated still picks up the correct text once data arrives. + const liveFrozenReports = frozenSelectedReports.map((frozen) => buildOptionFromReportID(frozen.reportID)); + const sections: SelectionListSections = []; if (!isLoading) { const selectedReportIDsSet = new Set(selectedReportIDs); const visibleReportIDsSet = new Set(chatOptions.recentReports.map((report) => report.reportID)); - const matchesSearchTerm = (report: OptionData) => - cleanSearchTerm === '' || !!report.text?.toLowerCase().includes(cleanSearchTerm) || !!report.login?.toLowerCase().includes(cleanSearchTerm); + + // Use the same matcher as filterAndOrderOptions (filterReports) so the frozen / extra-selected + // sections agree with Recents on what "matches" the search term — including dot-stripped login, + // alternateText, subtitle, and accent-normalized text. + const reportIDsMatchingSearch = + cleanSearchTerm === '' ? null : new Set(filterReports([...liveFrozenReports, ...selectedOptions] as SearchOptionData[], [cleanSearchTerm]).map((report) => report.reportID)); + const matchesSearchTerm = (report: OptionData) => reportIDsMatchingSearch === null || reportIDsMatchingSearch.has(report.reportID); // Pre-selected reports pinned at the top. Row order is frozen; the checkmark updates on toggle. // Search-filtered rows are hidden from view but stay in the snapshot so they still dedupe Recents below. - const frozenReports = buildFrozenSection(frozenSelectedReports.filter(matchesSearchTerm), (report) => selectedReportIDsSet.has(report.reportID)); + const frozenReports = buildFrozenSection(liveFrozenReports.filter(matchesSearchTerm), (report) => selectedReportIDsSet.has(report.reportID)); if (frozenReports.length > 0) { sections.push({ data: frozenReports, @@ -151,7 +170,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen sectionIndex: 2, }); } - const noResultsFound = didScreenTransitionEnd && sections.every((section) => section.data.length === 0); + const noResultsFound = didScreenTransitionEnd && !isLoading && sections.every((section) => section.data.length === 0); const headerMessage = noResultsFound ? translate('common.noResultsFound') : undefined; useEffect(() => { diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 9dd835e4b1a6..a359e28711af 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -89,11 +89,18 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldKeepSelectedInAvailableOptions: true, }); + // Flips true after the hydration effect at L259 has run at least once. Used to gate snapshot + // capture so we don't mistake "still hydrating" for "user has no pre-selection" — important when + // every initialAccountID is stale / paged out and the hydration effect yields an empty list + // (otherwise canCapture would stay false until the user toggles a row, then snapshot that row). + const [hasAttemptedHydration, setHasAttemptedHydration] = useState(initialAccountIDs.length === 0); + const {frozen: frozenSelectedOptions, isFrozen: isOptionFrozen} = useFrozenPreSelection({ selectedOptions, isReady: areOptionsInitialized, - // Don't capture until pre-selected options have hydrated from initialAccountIDs. - canCapture: initialAccountIDs.length === 0 || selectedOptions.length > 0, + // Gate on hydration (so stale-filter cases don't pin the first toggled row) AND on an empty + // search term (so visibleCount below reflects the unfiltered list, matching the chats selector). + canCapture: hasAttemptedHydration && debouncedSearchTerm.trim() === '', visibleCount: availableOptions.recentReports.length + availableOptions.personalDetails.length, getKeys: (option) => [option.accountID, option.login], }); @@ -302,6 +309,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, } setSelectedOptions(preSelectedOptions); + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot flag so the snapshot in useFrozenPreSelection doesn't fire before the hydration effect has run; derivable state isn't enough because the effect can resolve to an empty array. + setHasAttemptedHydration(true); // eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data }, [initialAccountIDs, personalDetails, recentAttendees, shouldAllowNameOnlyOptions]); diff --git a/src/hooks/useFrozenPreSelection.ts b/src/hooks/useFrozenPreSelection.ts index c91b1c342fbb..6eb227fa4f5f 100644 --- a/src/hooks/useFrozenPreSelection.ts +++ b/src/hooks/useFrozenPreSelection.ts @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useCallback, useMemo, useState} from 'react'; import CONST from '@src/CONST'; type UseFrozenPreSelectionOptions = { @@ -41,28 +41,35 @@ function useFrozenPreSelection({selectedOptions, isReady, visibleCount, canCa setFrozen(visibleCount >= threshold ? selectedOptions : []); } - const frozenList = frozen ?? []; - - const frozenKeys = new Set(); - for (const option of frozenList) { - for (const key of getKeys(option)) { - if (!isValidKey(key)) { - continue; + // Explicit useMemo / useCallback so isFrozen stays referentially stable for consumer useMemo deps, + // even if React Compiler bails on the imperative Set build. Both depend on `getKeys`, so callers + // should let React Compiler memoize the arrow they pass (or wrap it in useCallback themselves). + const frozenKeys = useMemo(() => { + const keys = new Set(); + for (const option of frozen ?? []) { + for (const key of getKeys(option)) { + if (!isValidKey(key)) { + continue; + } + keys.add(key); } - frozenKeys.add(key); } - } + return keys; + }, [frozen, getKeys]); - const isFrozen = (option: T) => { - for (const key of getKeys(option)) { - if (isValidKey(key) && frozenKeys.has(key)) { - return true; + const isFrozen = useCallback( + (option: T) => { + for (const key of getKeys(option)) { + if (isValidKey(key) && frozenKeys.has(key)) { + return true; + } } - } - return false; - }; + return false; + }, + [frozenKeys, getKeys], + ); - return {frozen: frozenList, isFrozen}; + return {frozen: frozen ?? [], isFrozen}; } export default useFrozenPreSelection; diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index 7a5954566207..ea97977c0b2a 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -24,6 +24,10 @@ function moveInitialSelectionToTop(items: T[], initia /** * Refreshes the `isSelected` flag on frozen rows without changing their order. Pair with * `useFrozenPreSelection` so selection indicators track the live selection while rows stay put. + * + * Callers must ensure each frozen item already has a stable `keyForList`. This helper only updates + * `isSelected`; if `keyForList` is missing it will stay missing and FlatList will complain about + * duplicate keys. */ function buildFrozenSection(frozen: T[], isCurrentlySelected: (item: T) => boolean): T[] { return frozen.map((item) => ({...item, isSelected: isCurrentlySelected(item)})); From 764f4da6c8e2f57240a64aa59718a26e0c95859e Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sat, 30 May 2026 06:29:58 +0000 Subject: [PATCH 15/35] Simplify comments and add unit tests for frozen pre-selection - Trim verbose multi-paragraph comments in the two selectors, the hook, and the section helpers to short lines explaining only the non-obvious why. - Add tests/unit/hooks/useFrozenPreSelection.test.ts covering capture timing, threshold gating, canCapture gate, snapshot stability, key validation, and isFrozen reference stability. - Extend SelectionListOrderUtilsTest.ts with cases for buildFrozenSection (refresh isSelected without reordering, preserve other fields, new array on each call) and excludeFrozenItems (drop / keep all / drop none). Co-authored-by: mkhutornyi --- .../Search/SearchFiltersChatsSelector.tsx | 26 +-- .../SearchFiltersParticipantsSelector.tsx | 18 +- src/hooks/useFrozenPreSelection.ts | 12 +- src/libs/SelectionListOrderUtils.ts | 8 +- tests/unit/SelectionListOrderUtilsTest.ts | 78 ++++++-- .../unit/hooks/useFrozenPreSelection.test.ts | 172 ++++++++++++++++++ 6 files changed, 259 insertions(+), 55 deletions(-) create mode 100644 tests/unit/hooks/useFrozenPreSelection.test.ts diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index e2f8f18997ab..98e80bd72155 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -73,9 +73,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); const sortedActions = useSortedActions(); - // Build an OptionData for a reportID from current Onyx state. Used for both the live selectedOptions - // list and the frozen section, so frozen rows pick up text / alternateText updates as Onyx hydrates - // rather than rendering the captured-at-snapshot-time values forever. + // Reads from current Onyx state, so frozen rows refresh their text when reports hydrate. const buildOptionFromReportID = (id: string): OptionData => { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`]; const reportData = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`]; @@ -115,18 +113,14 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const {frozen: frozenSelectedReports, isFrozen: isReportFrozen} = useFrozenPreSelection({ selectedOptions, isReady: !isLoading, - // Mirrors the participants selector — don't snapshot before the pre-selection has hydrated. - // Safe today (selectedReportIDs is seeded from useState(initialReportIDs)), but explicit so a - // future async initialReportIDs source doesn't silently snapshot an empty list. + // Wait for the pre-selection to hydrate before snapshotting, so a future async source can't snapshot an empty list. canCapture: initialReportIDs.length === 0 || selectedReportIDs.length > 0, - // Threshold is based on the unfiltered list so a search term active at first ready render doesn't permanently disable pinning. + // Use the unfiltered count so an active search at first render doesn't disable pinning. visibleCount: defaultOptions.recentReports.length, getKeys: (option) => [option.reportID], }); - // Rebuild frozen rows from current Onyx state each render. The snapshot only fixes which reports - // are pinned and their order; the display values stay live so a row captured before reports / - // personalDetails hydrated still picks up the correct text once data arrives. + // Rebuild each render — the snapshot fixes which reports are pinned, not what they display. const liveFrozenReports = frozenSelectedReports.map((frozen) => buildOptionFromReportID(frozen.reportID)); const sections: SelectionListSections = []; @@ -135,15 +129,13 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const selectedReportIDsSet = new Set(selectedReportIDs); const visibleReportIDsSet = new Set(chatOptions.recentReports.map((report) => report.reportID)); - // Use the same matcher as filterAndOrderOptions (filterReports) so the frozen / extra-selected - // sections agree with Recents on what "matches" the search term — including dot-stripped login, - // alternateText, subtitle, and accent-normalized text. + // Use filterReports so frozen / extra-selected sections match Recents on what counts as a hit. const reportIDsMatchingSearch = cleanSearchTerm === '' ? null : new Set(filterReports([...liveFrozenReports, ...selectedOptions] as SearchOptionData[], [cleanSearchTerm]).map((report) => report.reportID)); const matchesSearchTerm = (report: OptionData) => reportIDsMatchingSearch === null || reportIDsMatchingSearch.has(report.reportID); - // Pre-selected reports pinned at the top. Row order is frozen; the checkmark updates on toggle. - // Search-filtered rows are hidden from view but stay in the snapshot so they still dedupe Recents below. + // Pinned pre-selection: order is frozen, checkmark tracks live selection. Search-filtered rows + // stay in the snapshot so they still dedupe Recents below. const frozenReports = buildFrozenSection(liveFrozenReports.filter(matchesSearchTerm), (report) => selectedReportIDsSet.has(report.reportID)); if (frozenReports.length > 0) { sections.push({ @@ -152,7 +144,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen }); } - // Selected reports that don't show up anywhere else (e.g. dropped out of the current results). Surface them so they stay visible, but still respect the search term. + // Selected reports that don't show up anywhere else — surface them but respect the search term. const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && !isReportFrozen(report) && matchesSearchTerm(report)); if (extraSelectedReports.length > 0) { sections.push({ @@ -161,7 +153,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen }); } - // The rest of Recents in their natural position. Selected rows just get the checkmark — moving them would jump the scroll. + // Rest of Recents in their natural position. Selected rows just get the checkmark — moving them would jump the scroll. const visibleReports = excludeFrozenItems(chatOptions.recentReports, isReportFrozen).map((report) => selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report, ); diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index a359e28711af..9a60bf43eb21 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -89,17 +89,15 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldKeepSelectedInAvailableOptions: true, }); - // Flips true after the hydration effect at L259 has run at least once. Used to gate snapshot - // capture so we don't mistake "still hydrating" for "user has no pre-selection" — important when - // every initialAccountID is stale / paged out and the hydration effect yields an empty list - // (otherwise canCapture would stay false until the user toggles a row, then snapshot that row). + // Set once the hydration effect runs, so the snapshot waits for it. Without this, a fully stale + // initialAccountIDs list would let the snapshot fire on the first toggled row and pin it. const [hasAttemptedHydration, setHasAttemptedHydration] = useState(initialAccountIDs.length === 0); const {frozen: frozenSelectedOptions, isFrozen: isOptionFrozen} = useFrozenPreSelection({ selectedOptions, isReady: areOptionsInitialized, - // Gate on hydration (so stale-filter cases don't pin the first toggled row) AND on an empty - // search term (so visibleCount below reflects the unfiltered list, matching the chats selector). + // Wait for hydration (so a toggled row isn't mistaken for pre-selection) and an empty search + // term (so visibleCount reflects the full list). canCapture: hasAttemptedHydration && debouncedSearchTerm.trim() === '', visibleCount: availableOptions.recentReports.length + availableOptions.personalDetails.length, getKeys: (option) => [option.accountID, option.login], @@ -123,8 +121,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); const matchesSearchTerm = (option: OptionData) => !trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(option, option.accountID ?? CONST.DEFAULT_NUMBER_ID, trimmedSearchTerm); - // Pre-selected items pinned at the top. Row order is frozen; the checkmark updates on toggle. - // Search-filtered rows are hidden from view but stay in the snapshot so they still dedupe Recents / Contacts below. + // Pinned pre-selection: order is frozen, checkmark tracks live selection. Search-filtered rows + // stay in the snapshot so they still dedupe Recents / Contacts below. const isOptionCurrentlySelected = (option: OptionData) => selectedOptions.some( (selected) => @@ -140,7 +138,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, }); } - // Selected items that don't show up anywhere else (e.g. name-only attendees). Surface them so they stay visible, but still respect the search term. + // Selected items that don't show up anywhere else (e.g. name-only attendees) — surface them but respect the search term. const visibleLogins = new Set([...chatOptions.personalDetails.map((detail) => detail.login), ...chatOptions.recentReports.map((report) => report.login)].filter(Boolean)); const extraSelectedOptions = selectedOptions.filter( (option) => option.accountID !== currentUserAccountID && !visibleLogins.has(option.login) && !isOptionFrozen(option) && matchesSearchTerm(option), @@ -309,7 +307,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, } setSelectedOptions(preSelectedOptions); - // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot flag so the snapshot in useFrozenPreSelection doesn't fire before the hydration effect has run; derivable state isn't enough because the effect can resolve to an empty array. + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot flag so the snapshot waits for hydration; derivable state isn't enough since the effect can resolve to an empty array. setHasAttemptedHydration(true); // eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data }, [initialAccountIDs, personalDetails, recentAttendees, shouldAllowNameOnlyOptions]); diff --git a/src/hooks/useFrozenPreSelection.ts b/src/hooks/useFrozenPreSelection.ts index 6eb227fa4f5f..5966d36ef99a 100644 --- a/src/hooks/useFrozenPreSelection.ts +++ b/src/hooks/useFrozenPreSelection.ts @@ -26,10 +26,9 @@ function isValidKey(key: string | number | undefined | null): key is string | nu } /** - * Pins the items that were pre-selected on first load to the top of a long list, so they don't - * get lost when the user starts toggling things. Returns the frozen snapshot and an `isFrozen` - * predicate for deduping rows elsewhere. Captures during render so callers can use it on the - * same render where data first becomes available — no extra renders. + * Pins items that were pre-selected on first load to the top of a long list, so they don't get lost + * when the user starts toggling. Returns the snapshot plus an `isFrozen` predicate for deduping. + * Captures during render — no extra renders. */ function useFrozenPreSelection({selectedOptions, isReady, visibleCount, canCapture = true, threshold = CONST.STANDARD_LIST_ITEM_LIMIT, getKeys}: UseFrozenPreSelectionOptions): { frozen: T[]; @@ -41,9 +40,8 @@ function useFrozenPreSelection({selectedOptions, isReady, visibleCount, canCa setFrozen(visibleCount >= threshold ? selectedOptions : []); } - // Explicit useMemo / useCallback so isFrozen stays referentially stable for consumer useMemo deps, - // even if React Compiler bails on the imperative Set build. Both depend on `getKeys`, so callers - // should let React Compiler memoize the arrow they pass (or wrap it in useCallback themselves). + // Explicit useMemo / useCallback so isFrozen stays stable for consumer useMemo deps — + // React Compiler can bail on the imperative Set build. const frozenKeys = useMemo(() => { const keys = new Set(); for (const option of frozen ?? []) { diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index ea97977c0b2a..4333acc80053 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -22,12 +22,8 @@ function moveInitialSelectionToTop(items: T[], initia } /** - * Refreshes the `isSelected` flag on frozen rows without changing their order. Pair with - * `useFrozenPreSelection` so selection indicators track the live selection while rows stay put. - * - * Callers must ensure each frozen item already has a stable `keyForList`. This helper only updates - * `isSelected`; if `keyForList` is missing it will stay missing and FlatList will complain about - * duplicate keys. + * Refreshes the `isSelected` flag on frozen rows without reordering them. Each frozen item must + * already have a stable `keyForList`; this helper won't add one. */ function buildFrozenSection(frozen: T[], isCurrentlySelected: (item: T) => boolean): T[] { return frozen.map((item) => ({...item, isSelected: isCurrentlySelected(item)})); diff --git a/tests/unit/SelectionListOrderUtilsTest.ts b/tests/unit/SelectionListOrderUtilsTest.ts index 4f926634e9d9..72cd37063d20 100644 --- a/tests/unit/SelectionListOrderUtilsTest.ts +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -1,25 +1,73 @@ -import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop, {buildFrozenSection, excludeFrozenItems} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; describe('SelectionListOrderUtils', () => { - it('does not reorder values when the list is under the global threshold', () => { - const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}, (_, index) => ({ - value: `item-${index}`, - keyForList: `item-${index}`, - })); + describe('moveInitialSelectionToTop', () => { + it('does not reorder values when the list is under the global threshold', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); - expect(moveInitialSelectionToTop(items, ['item-3'])).toEqual(items); + expect(moveInitialSelectionToTop(items, ['item-3'])).toEqual(items); + }); + + it('moves the initially selected values to the top while preserving source order', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); + const selectedValues = [`item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}`, `item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 1}`]; + + const reorderedItems = moveInitialSelectionToTop(items, selectedValues); + + expect(reorderedItems.map((item) => item.value)).toEqual([...selectedValues, ...items.filter((item) => !selectedValues.includes(item.value)).map((item) => item.value)]); + }); }); - it('moves the initially selected values to the top while preserving source order', () => { - const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ - value: `item-${index}`, - keyForList: `item-${index}`, - })); - const selectedValues = [`item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}`, `item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 1}`]; + describe('buildFrozenSection', () => { + it('refreshes isSelected on each row from the predicate without reordering', () => { + const frozen = [ + {id: 1, isSelected: true}, + {id: 2, isSelected: true}, + {id: 3, isSelected: true}, + ]; + const isCurrentlySelected = (item: {id: number}) => item.id !== 2; + + expect(buildFrozenSection(frozen, isCurrentlySelected)).toEqual([ + {id: 1, isSelected: true}, + {id: 2, isSelected: false}, + {id: 3, isSelected: true}, + ]); + }); + + it('preserves all other fields on the row', () => { + const frozen = [{id: 1, label: 'first', isSelected: false}]; + expect(buildFrozenSection(frozen, () => true)).toEqual([{id: 1, label: 'first', isSelected: true}]); + }); + + it('returns a new array on every call so React sees a fresh reference', () => { + const frozen = [{id: 1, isSelected: true}]; + expect(buildFrozenSection(frozen, () => true)).not.toBe(frozen); + }); + }); + + describe('excludeFrozenItems', () => { + it('drops the items the predicate marks as frozen', () => { + const items = [{id: 1}, {id: 2}, {id: 3}]; + const isFrozen = (item: {id: number}) => item.id === 2; + + expect(excludeFrozenItems(items, isFrozen)).toEqual([{id: 1}, {id: 3}]); + }); - const reorderedItems = moveInitialSelectionToTop(items, selectedValues); + it('returns every item when the predicate matches nothing', () => { + const items = [{id: 1}, {id: 2}]; + expect(excludeFrozenItems(items, () => false)).toEqual(items); + }); - expect(reorderedItems.map((item) => item.value)).toEqual([...selectedValues, ...items.filter((item) => !selectedValues.includes(item.value)).map((item) => item.value)]); + it('returns an empty array when the predicate matches everything', () => { + const items = [{id: 1}, {id: 2}]; + expect(excludeFrozenItems(items, () => true)).toEqual([]); + }); }); }); diff --git a/tests/unit/hooks/useFrozenPreSelection.test.ts b/tests/unit/hooks/useFrozenPreSelection.test.ts new file mode 100644 index 000000000000..28907e1a408b --- /dev/null +++ b/tests/unit/hooks/useFrozenPreSelection.test.ts @@ -0,0 +1,172 @@ +import {renderHook} from '@testing-library/react-native'; +import useFrozenPreSelection from '@hooks/useFrozenPreSelection'; +import CONST from '@src/CONST'; + +type Option = {accountID?: number; login?: string}; + +const getKeys = (option: Option) => [option.accountID, option.login]; + +const longList = CONST.STANDARD_LIST_ITEM_LIMIT; +const shortList = CONST.STANDARD_LIST_ITEM_LIMIT - 1; + +describe('useFrozenPreSelection', () => { + it('does not capture until the list is ready', () => { + const selectedOptions: Option[] = [{accountID: 1, login: 'a@example.com'}]; + + const {result} = renderHook(() => + useFrozenPreSelection