-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Optimize InviteReportParticipantsPage component via useSearchSelector hook #66964
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mountiny
merged 44 commits into
Expensify:main
from
callstack-internal:perf/search-components-optimization
Sep 16, 2025
Merged
Changes from all commits
Commits
Show all changes
44 commits
Select commit
Hold shift + click to select a range
fe5d3fe
Refactor: Introduce useSearchSelector Hook and Clean Up Invite
sosek108 7fd4455
Merge branch 'perf/share-tab-optimization' into perf/search-component…
sosek108 91b7fee
Add phone contact' related logic to useSearchSelector
sosek108 acd3797
Cleanup the code
sosek108 fa50035
fix
sosek108 cf33796
Merge branch 'main' into perf/search-components-optimization
sosek108 2c9d2dc
Lint fixes
sosek108 df576d3
revert MoneyRequestAttendeeSelector changes
sosek108 e7a8b2a
revert WorkspaceInvitePage changes
sosek108 ee42159
InviteReportParticipantsPage modification and useSearchSelector changes
sosek108 f33734c
use of useContactImport
sosek108 e9becf1
prettier fix
sosek108 a4da5da
Merge branch 'main' into perf/search-components-optimization
sosek108 27118e5
Merge branch 'main' into perf/search-components-optimization
sosek108 66bd8a0
InviteReportParticipantsPage optimization
sosek108 7a83c43
contacts memoization
sosek108 3e29991
Merge branch 'main' into perf/search-components-optimization
sosek108 506704e
should initialize
sosek108 74d424a
should initialize
sosek108 55924c6
Merge branch 'main' into perf/search-components-optimization
sosek108 e5130ed
fixes related to code review. Move static strings to CONST. Remove
sosek108 ba04c28
Merge branch 'main' into perf/search-components-optimization
sosek108 def2c8f
IsValidReport type reintroduction
sosek108 07e15ab
Introduce integration with SelectionList pagination via onEndReached
sosek108 66f35c0
should skip button
sosek108 81b6012
remove console.log
sosek108 ce5f4d0
Merge branch 'main' into perf/search-components-optimization
sosek108 fb038e8
Introduce search by email/phone
sosek108 34c1761
Do not show loader when list is already computed
sosek108 259cb85
empty list error removed when selected items
sosek108 00264cb
Selected options for display
sosek108 afe29f5
update imports
sosek108 d9e30e8
Merge branch 'main' into perf/search-components-optimization
sosek108 3921034
Merge branch 'main' into perf/search-components-optimization
sosek108 2c0b055
remove shouldSkipShowMoreButton prop which does not exist anymore
sosek108 6f5c9d1
Fix header message sent to SelectionList
sosek108 dabf512
Merge branch 'main' into perf/search-components-optimization
sosek108 9529acc
update to hook
sosek108 cc1cd33
Merge branch 'main' into perf/search-components-optimization
sosek108 1ba393b
comment additional tabs for easy of read
sosek108 2c5a13f
split logic into web and native parts.
sosek108 1de1889
var rename for best practices
sosek108 651e6da
rename callback for best practices
sosek108 a347856
lint fixes
sosek108 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,291 @@ | ||
| import {useCallback, useMemo, useState} from 'react'; | ||
| import type {PermissionStatus} from 'react-native-permissions'; | ||
| import {useOptionsList} from '@components/OptionListContextProvider'; | ||
| import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; | ||
| import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; | ||
| import type {OptionData} from '@libs/ReportUtils'; | ||
| import CONST from '@src/CONST'; | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
| import type {PersonalDetails} from '@src/types/onyx'; | ||
| import useDebouncedState from './useDebouncedState'; | ||
| import useOnyx from './useOnyx'; | ||
|
|
||
| type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick<typeof CONST.SEARCH_SELECTOR, 'SEARCH_CONTEXT_GENERAL' | 'SEARCH_CONTEXT_SEARCH' | 'SEARCH_CONTEXT_MEMBER_INVITE'>]; | ||
| type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick<typeof CONST.SEARCH_SELECTOR, 'SELECTION_MODE_SINGLE' | 'SELECTION_MODE_MULTI'>]; | ||
|
|
||
| type UseSearchSelectorConfig = { | ||
| /** Selection mode - single or multiple selection */ | ||
| selectionMode: SearchSelectorSelectionMode; | ||
|
|
||
| /** Maximum number of results to return (for heap optimization) */ | ||
| maxResultsPerPage?: number; | ||
|
|
||
| /** What is the context that we are using this hook for */ | ||
| searchContext?: SearchSelectorContext; | ||
|
|
||
| /** Whether to include user to invite option */ | ||
| includeUserToInvite?: boolean; | ||
|
|
||
| /** Logins to exclude from results */ | ||
| excludeLogins?: Record<string, boolean>; | ||
|
|
||
| /** Whether to include recent reports (for getMemberInviteOptions) */ | ||
| includeRecentReports?: boolean; | ||
|
|
||
| /** Enable phone contacts integration */ | ||
| enablePhoneContacts?: boolean; | ||
|
|
||
| /** Additional configuration for getValidOptions function */ | ||
| getValidOptionsConfig?: Partial<GetOptionsConfig>; | ||
|
|
||
| /** Callback when selection changes (multi-select mode) */ | ||
| onSelectionChange?: (selected: OptionData[]) => void; | ||
|
|
||
| /** Callback when single option is selected (single-select mode) */ | ||
| onSingleSelect?: (option: OptionData) => void; | ||
|
|
||
| /** Initial selected options */ | ||
| initialSelected?: OptionData[]; | ||
|
|
||
| /** Whether to initialize the hook */ | ||
| shouldInitialize?: boolean; | ||
|
|
||
| /** Additional contact options to merge (used by platform-specific implementations) */ | ||
| contactOptions?: Array<SearchOption<PersonalDetails>>; | ||
| }; | ||
|
|
||
| type ContactState = { | ||
| /** Current permission status */ | ||
| permissionStatus: PermissionStatus; | ||
|
|
||
| /** Contact options from device */ | ||
| contactOptions: Array<SearchOption<PersonalDetails>>; | ||
|
|
||
| /** Whether to show import UI */ | ||
| showImportUI: boolean; | ||
|
|
||
| /** Function to trigger contact import */ | ||
| importContacts: () => void; | ||
|
|
||
| /** Function to initiate contact import and set state */ | ||
| initiateContactImportAndSetState: () => void; | ||
|
|
||
| /** Function to set permission state */ | ||
| setContactPermissionState: (status: PermissionStatus) => void; | ||
| }; | ||
|
|
||
| type UseSearchSelectorReturn = { | ||
| /** Current search term */ | ||
| searchTerm: string; | ||
|
|
||
| /** Function to update search term */ | ||
| setSearchTerm: (value: string) => void; | ||
|
|
||
| /** Filtered and optimized search options with selection state */ | ||
| searchOptions: Options; | ||
|
|
||
| /** Available (unselected) options */ | ||
| availableOptions: Options; | ||
|
|
||
| /** Currently selected options. This returns all selected options and are not affected by search term */ | ||
| selectedOptions: OptionData[]; | ||
|
|
||
| /** Currently selected options used for list display. This prop can be used in selection list to display selected options that are filtered by search term */ | ||
| selectedOptionsForDisplay: OptionData[]; | ||
|
|
||
| /** Function to set selected options */ | ||
| setSelectedOptions: (options: OptionData[]) => void; | ||
|
|
||
| /** Function to toggle selection state of an option */ | ||
| toggleSelection: (option: OptionData) => void; | ||
|
|
||
| /** Whether options are initialized */ | ||
| areOptionsInitialized: boolean; | ||
|
|
||
| /** Contact-related state and functions (when enablePhoneContacts is true) */ | ||
| contactState?: ContactState; | ||
|
|
||
| /** Callback to handle list end reached */ | ||
| onListEndReached: () => void; | ||
| }; | ||
|
|
||
| /** | ||
| * Base hook that provides search functionality with selection logic for option lists. | ||
| * This contains the core logic without platform-specific dependencies. | ||
| */ | ||
| function useSearchSelectorBase({ | ||
| selectionMode, | ||
| maxResultsPerPage = CONST.MAX_SELECTION_LIST_PAGE_LENGTH, | ||
| searchContext = 'search', | ||
| includeUserToInvite = true, | ||
| excludeLogins = CONST.EMPTY_OBJECT, | ||
| includeRecentReports = false, | ||
| getValidOptionsConfig = CONST.EMPTY_OBJECT, | ||
| onSelectionChange, | ||
| onSingleSelect, | ||
| initialSelected, | ||
| shouldInitialize = true, | ||
| contactOptions, | ||
| }: UseSearchSelectorConfig): UseSearchSelectorReturn { | ||
| const {options: defaultOptions, areOptionsInitialized} = useOptionsList({ | ||
| shouldInitialize, | ||
| }); | ||
|
|
||
| const optionsWithContacts = useMemo(() => { | ||
| if (!contactOptions?.length || !areOptionsInitialized) { | ||
| return defaultOptions; | ||
| } | ||
| const personalDetailsWithContacts = defaultOptions.personalDetails.concat(contactOptions); | ||
| return { | ||
| ...defaultOptions, | ||
| personalDetails: personalDetailsWithContacts, | ||
| }; | ||
| }, [areOptionsInitialized, defaultOptions, contactOptions]); | ||
| const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); | ||
| const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); | ||
| const [selectedOptions, setSelectedOptions] = useState<OptionData[]>(initialSelected ?? []); | ||
| const [maxResults, setMaxResults] = useState(maxResultsPerPage); | ||
| const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); | ||
|
|
||
| const onListEndReached = useCallback(() => { | ||
| setMaxResults((previous) => previous + maxResultsPerPage); | ||
| }, [maxResultsPerPage]); | ||
|
|
||
| const computedSearchTerm = useMemo(() => { | ||
| return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); | ||
| }, [debouncedSearchTerm, countryCode]); | ||
|
|
||
| const baseOptions = useMemo(() => { | ||
| if (!areOptionsInitialized) { | ||
| return getEmptyOptions(); | ||
| } | ||
|
|
||
| switch (searchContext) { | ||
| case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH: | ||
| return getSearchOptions(optionsWithContacts, betas ?? [], true, true, computedSearchTerm, maxResults, includeUserToInvite); | ||
| case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE: | ||
| return getValidOptions(optionsWithContacts, { | ||
| betas: betas ?? [], | ||
| includeP2P: true, | ||
| includeSelectedOptions: false, | ||
| excludeLogins, | ||
| includeRecentReports, | ||
| maxElements: maxResults, | ||
| searchString: computedSearchTerm, | ||
| }); | ||
| case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: | ||
| return getValidOptions(optionsWithContacts, { | ||
| ...getValidOptionsConfig, | ||
| betas: betas ?? [], | ||
| searchString: computedSearchTerm, | ||
| maxElements: maxResults, | ||
| includeUserToInvite, | ||
| loginsToExclude: excludeLogins, | ||
| }); | ||
| default: | ||
| return getEmptyOptions(); | ||
| } | ||
| }, [areOptionsInitialized, optionsWithContacts, betas, computedSearchTerm, maxResults, searchContext, includeUserToInvite, excludeLogins, includeRecentReports, getValidOptionsConfig]); | ||
|
|
||
| const isOptionSelected = useMemo(() => { | ||
| return (option: OptionData) => | ||
| selectedOptions.some( | ||
| (selected) => | ||
| (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison | ||
| (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison | ||
| (selected.login && selected.login === option.login), | ||
| ); | ||
| }, [selectedOptions]); | ||
|
|
||
| const searchOptions = useMemo(() => { | ||
| return { | ||
| ...baseOptions, | ||
| personalDetails: baseOptions.personalDetails.map((option) => ({ | ||
| ...option, | ||
| isSelected: isOptionSelected(option), | ||
| })), | ||
| recentReports: baseOptions.recentReports.map((option) => ({ | ||
| ...option, | ||
| isSelected: isOptionSelected(option), | ||
| })), | ||
| userToInvite: baseOptions.userToInvite | ||
| ? { | ||
| ...baseOptions.userToInvite, | ||
| isSelected: isOptionSelected(baseOptions.userToInvite), | ||
| } | ||
| : null, | ||
| }; | ||
| }, [baseOptions, isOptionSelected]); | ||
|
|
||
| const availableOptions = useMemo(() => { | ||
| const unselectedRecentReports = searchOptions.recentReports.filter((option) => !option.isSelected); | ||
|
|
||
| // Filter out people who appear in recent reports from personal details (recents take priority) | ||
| const recentReportLogins = new Set(unselectedRecentReports.map((option) => option.login).filter(Boolean)); | ||
| const unselectedPersonalDetails = searchOptions.personalDetails.filter((option) => !option.isSelected && !recentReportLogins.has(option.login)); | ||
|
|
||
| return { | ||
| ...searchOptions, | ||
| personalDetails: unselectedPersonalDetails, | ||
| recentReports: unselectedRecentReports, | ||
| userToInvite: searchOptions.userToInvite?.isSelected ? null : searchOptions.userToInvite, | ||
| }; | ||
| }, [searchOptions]); | ||
|
|
||
| /** | ||
| * Toggle selection state of option based on selection mode | ||
| */ | ||
| const toggleSelection = useCallback( | ||
| (option: OptionData) => { | ||
| if (selectionMode === CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE) { | ||
| onSingleSelect?.(option); | ||
| return; | ||
| } | ||
|
|
||
| const isSelected = selectedOptions.some( | ||
| (selected) => | ||
| (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison | ||
| (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison | ||
| (selected.login && selected.login === option.login), | ||
| ); | ||
|
|
||
| const newSelected = isSelected | ||
| ? selectedOptions.filter( | ||
| (selected) => | ||
| !( | ||
| (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison | ||
| (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison | ||
| (selected.login && selected.login === option.login) | ||
| ), | ||
| ) | ||
| : [...selectedOptions, {...option, isSelected: true}]; | ||
|
|
||
| setSelectedOptions(newSelected); | ||
| onSelectionChange?.(newSelected); | ||
| }, | ||
| [selectedOptions, selectionMode, onSelectionChange, onSingleSelect], | ||
| ); | ||
|
|
||
| const selectedOptionsForDisplay = useMemo(() => { | ||
| return selectedOptions.filter((option) => { | ||
| return !!option.text?.toLowerCase().includes(computedSearchTerm) || !!option.login?.toLowerCase().includes(computedSearchTerm); | ||
| }); | ||
| }, [selectedOptions, computedSearchTerm]); | ||
|
|
||
| return { | ||
| searchTerm, | ||
| setSearchTerm, | ||
| searchOptions, | ||
| availableOptions, | ||
| selectedOptions, | ||
| setSelectedOptions, | ||
| toggleSelection, | ||
| areOptionsInitialized, | ||
| contactState: undefined, | ||
| onListEndReached, | ||
| selectedOptionsForDisplay, | ||
| }; | ||
| } | ||
|
|
||
| export default useSearchSelectorBase; | ||
| export type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn, SearchSelectorContext, SearchSelectorSelectionMode}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import {useCallback, useMemo} from 'react'; | ||
| import {InteractionManager} from 'react-native'; | ||
| import {RESULTS} from 'react-native-permissions'; | ||
| import useContactImport from './useContactImport'; | ||
| import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './useSearchSelector.base'; | ||
| import useSearchSelectorBase from './useSearchSelector.base'; | ||
|
|
||
| /** | ||
| * Hook that combines search functionality with selection logic for option lists. | ||
| * Leverages heap optimization for performance with large datasets. | ||
| * Native version includes phone contacts integration. | ||
| * | ||
| * @param config - Configuration object for the hook | ||
| * @returns Object with search and selection utilities | ||
| */ | ||
| function useSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorReturn { | ||
| const {enablePhoneContacts = false} = config; | ||
|
|
||
| // Phone contacts logic | ||
| const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); | ||
| const memoizedContacts = useMemo(() => (contacts.length ? contacts : []), [contacts]); | ||
| const showImportContacts = enablePhoneContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); | ||
|
|
||
| const initiateContactImportAndSetState = useCallback(() => { | ||
| setContactPermissionState(RESULTS.GRANTED); | ||
| InteractionManager.runAfterInteractions(importAndSaveContacts); | ||
| }, [importAndSaveContacts, setContactPermissionState]); | ||
|
|
||
| // Use base hook with contact options | ||
| const baseResult = useSearchSelectorBase({ | ||
| ...config, | ||
| contactOptions: enablePhoneContacts ? memoizedContacts : undefined, | ||
| }); | ||
|
|
||
| // Build contact state if enabled | ||
| const contactState: ContactState | undefined = enablePhoneContacts | ||
| ? { | ||
| permissionStatus: contactPermissionState, | ||
| contactOptions: contacts, | ||
| showImportUI: showImportContacts, | ||
| importContacts: importAndSaveContacts, | ||
| initiateContactImportAndSetState, | ||
| setContactPermissionState, | ||
| } | ||
| : undefined; | ||
|
|
||
| return { | ||
| ...baseResult, | ||
| contactState, | ||
| }; | ||
| } | ||
|
|
||
| export default useSearchSelector; | ||
| export type {ContactState}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import useSearchSelectorBase from './useSearchSelector.base'; | ||
| import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './useSearchSelector.base'; | ||
|
|
||
| /** | ||
| * Hook that combines search functionality with selection logic for option lists. | ||
| * Leverages heap optimization for performance with large datasets. | ||
| * Web/desktop version without phone contacts integration. | ||
| * | ||
| * @param config - Configuration object for the hook | ||
| * @returns Object with search and selection utilities | ||
| */ | ||
| function useSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorReturn { | ||
| return useSearchSelectorBase(config); | ||
| } | ||
|
|
||
| export default useSearchSelector; | ||
| export type {ContactState}; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was an infinite loop: the list reached its end and triggered a fetch, but no new data was available, so the list rerendered and reached its end again.
Debounce solved it partly but still crashed on low-end devices.
The fix was to only fetch new data when there is more data to fetch.