diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index e792811406c3..92828c1fa0cc 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -59,19 +59,20 @@ function InviteReportParticipantsPage({report}: InviteReportParticipantsPageProp return res; }, [report]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, - includeUserToInvite: true, - excludeLogins: excludedUsers, - includeRecentReports: true, - shouldInitialize: didScreenTransitionEnd, - }); + const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, onListEndReached} = + useSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + includeUserToInvite: true, + excludeLogins: excludedUsers, + includeRecentReports: true, + shouldInitialize: didScreenTransitionEnd, + }); useEffect(() => { - updateUserSearchPhrase(searchTerm); - searchInServer(searchTerm); - }, [searchTerm]); + updateUserSearchPhrase(debouncedSearchTerm); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); const sections = useMemo(() => { const sectionsArray: Sections = []; @@ -149,7 +150,7 @@ function InviteReportParticipantsPage({report}: InviteReportParticipantsPageProp }, [selectedOptions, goBack, reportID, validate, formatPhoneNumber]); const headerMessage = useMemo(() => { - const processedLogin = searchTerm.trim().toLowerCase(); + const processedLogin = debouncedSearchTerm.trim().toLowerCase(); const expensifyEmails = CONST.EXPENSIFY_EMAILS; if (!availableOptions.userToInvite && expensifyEmails.includes(processedLogin)) { return translate('messages.errorMessageInvalidEmail'); @@ -170,7 +171,7 @@ function InviteReportParticipantsPage({report}: InviteReportParticipantsPageProp false, ); }, [ - searchTerm, + debouncedSearchTerm, availableOptions.userToInvite, availableOptions.recentReports.length, availableOptions.personalDetails.length, diff --git a/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx b/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx index 2a7acc445a68..2e302508b141 100644 --- a/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx +++ b/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx @@ -68,14 +68,15 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo ); }, [policy?.employeeList]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, searchOptions} = useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, - includeUserToInvite: true, - excludeLogins: excludedUsers, - includeRecentReports: false, - shouldInitialize: didScreenTransitionEnd, - }); + const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, searchOptions} = + useSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + includeUserToInvite: true, + excludeLogins: excludedUsers, + includeRecentReports: false, + shouldInitialize: didScreenTransitionEnd, + }); const welcomeNoteSubject = useMemo( () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, @@ -85,8 +86,8 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo const welcomeNote = useMemo(() => translate('workspace.common.welcomeNote'), [translate]); useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); const sections: Sections[] = useMemo(() => { const sectionsArr: Sections[] = []; @@ -194,7 +195,7 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo }, [completeOnboarding, onboardingPolicyID, policy?.employeeList, selectedOptions, welcomeNote, welcomeNoteSubject, formatPhoneNumber]); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); + const searchValue = debouncedSearchTerm.trim().toLowerCase(); if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } @@ -206,7 +207,7 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo } return getHeaderMessage(searchOptions.personalDetails.length + selectedOptions.length !== 0, !!searchOptions.userToInvite, searchValue, countryCode, false); }, [ - searchTerm, + debouncedSearchTerm, availableOptions.userToInvite, excludedUsers, countryCode, diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 97f73818dfa8..53b5364687d1 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -214,7 +214,7 @@ function MoneyRequestParticipantsSelector({ [isIOUSplit, iouType, onParticipantsAdded], ); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached, contactState} = useSearchSelector({ + const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached, contactState} = useSearchSelector({ selectionMode: isIOUSplit ? CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI : CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, includeUserToInvite: !isCategorizeOrShareAction && !isPerDiemRequest, @@ -235,11 +235,11 @@ function MoneyRequestParticipantsSelector({ }, }); - const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]); + const cleanSearchTerm = useMemo(() => debouncedSearchTerm.trim().toLowerCase(), [debouncedSearchTerm]); useEffect(() => { - searchInServer(searchTerm.trim()); - }, [searchTerm]); + searchInServer(debouncedSearchTerm.trim()); + }, [debouncedSearchTerm]); const inputHelperText = useMemo( () => @@ -247,7 +247,7 @@ function MoneyRequestParticipantsSelector({ (availableOptions.personalDetails ?? []).length + (availableOptions.recentReports ?? []).length + (availableOptions.workspaceChats ?? []).length !== 0 || !isEmptyObject(availableOptions.selfDMChat), !!availableOptions?.userToInvite, - searchTerm.trim(), + debouncedSearchTerm.trim(), countryCode, participants.some((participant) => getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm)), ), @@ -260,7 +260,7 @@ function MoneyRequestParticipantsSelector({ availableOptions?.userToInvite, availableOptions.workspaceChats, cleanSearchTerm, - searchTerm, + debouncedSearchTerm, participants, countryCode, ], diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index 5d1124b4cf82..2d09aa9960f7 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -35,7 +35,7 @@ function VacationDelegatePage() { const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {canBeMissing: true}); const currentVacationDelegate = vacationDelegate?.delegate; const delegatePersonalDetails = getPersonalDetailByEmail(currentVacationDelegate ?? ''); - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar'] as const); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const excludeLogins = useMemo( () => ({ @@ -45,7 +45,7 @@ function VacationDelegatePage() { [currentVacationDelegate], ); - const {searchTerm, setSearchTerm, availableOptions, areOptionsInitialized, onListEndReached} = useSearchSelector({ + const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, areOptionsInitialized, onListEndReached} = useSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, @@ -60,11 +60,11 @@ function VacationDelegatePage() { return getHeaderMessage( (availableOptions.recentReports?.length || 0) + (availableOptions.personalDetails?.length || 0) !== 0, !!availableOptions.userToInvite, - searchTerm.trim(), + debouncedSearchTerm.trim(), countryCode, false, ); - }, [availableOptions.recentReports?.length, availableOptions.personalDetails?.length, availableOptions.userToInvite, searchTerm, countryCode]); + }, [availableOptions.recentReports?.length, availableOptions.personalDetails?.length, availableOptions.userToInvite, debouncedSearchTerm, countryCode]); const sections = useMemo(() => { const sectionsList = []; @@ -161,8 +161,8 @@ function VacationDelegatePage() { ); useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); return ( <> diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index 92cd12eaed91..a14e976da4e7 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -95,16 +95,26 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { }); }, [invitedEmailsToAccountIDsDraft, personalDetails]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, onListEndReached, searchOptions} = - useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, - includeUserToInvite: true, - excludeLogins: excludedUsers, - includeRecentReports: false, - shouldInitialize: didScreenTransitionEnd, - initialSelected: initiallySelectedOptions, - }); + const { + searchTerm, + debouncedSearchTerm, + setSearchTerm, + availableOptions, + selectedOptions, + selectedOptionsForDisplay, + toggleSelection, + areOptionsInitialized, + onListEndReached, + searchOptions, + } = useSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + includeUserToInvite: true, + excludeLogins: excludedUsers, + includeRecentReports: false, + shouldInitialize: didScreenTransitionEnd, + initialSelected: initiallySelectedOptions, + }); const sections: Sections[] = useMemo(() => { const sectionsArr: Sections[] = []; @@ -180,7 +190,7 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); + const searchValue = debouncedSearchTerm.trim().toLowerCase(); if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } @@ -192,7 +202,7 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { } return getHeaderMessage(searchOptions.personalDetails.length + selectedOptions.length !== 0, !!searchOptions.userToInvite, searchValue, countryCode, false); }, [ - searchTerm, + debouncedSearchTerm, availableOptions.userToInvite, excludedUsers, countryCode, @@ -219,8 +229,8 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); return ( { - searchInServer(searchTerm); - }, [searchTerm]); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); + const searchValue = debouncedSearchTerm.trim().toLowerCase(); if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } return getHeaderMessage(assignees.length > 0, !!availableOptions.userToInvite, searchValue, countryCode, false); - }, [searchTerm, availableOptions.userToInvite, assignees?.length, countryCode, translate]); + }, [debouncedSearchTerm, availableOptions.userToInvite, assignees?.length, countryCode, translate]); const textInputOptions = useMemo( () => ({ diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 2156dd12fa56..8d2df39d9c84 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -1,12 +1,12 @@ import React, {useEffect, useMemo, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -44,6 +44,7 @@ type AssigneeStepProps = { function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepProps) { const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const {isOffline} = useNetwork(); const policyID = route.params.policyID; const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {canBeMissing: true}); @@ -136,7 +137,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr isSelected: issueNewCard?.data?.assigneeEmail === email, icons: [ { - source: personalDetail?.avatar ?? Expensicons.FallbackAvatar, + source: personalDetail?.avatar ?? icons.FallbackAvatar, name: formatPhoneNumber(email), type: CONST.ICON_TYPE_AVATAR, id: personalDetail?.accountID, @@ -148,7 +149,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr membersList = sortAlphabetically(membersList, 'text', localeCompare); return membersList; - }, [policy?.employeeList, localeCompare, isOffline, issueNewCard?.data?.assigneeEmail, formatPhoneNumber]); + }, [policy?.employeeList, localeCompare, isOffline, issueNewCard?.data?.assigneeEmail, formatPhoneNumber, icons.FallbackAvatar]); const assignees = useMemo(() => { if (!debouncedSearchTerm) { @@ -187,16 +188,16 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr ]); useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); + const searchValue = debouncedSearchTerm.trim().toLowerCase(); if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } return getHeaderMessage(assignees.length > 0, !!availableOptions.userToInvite, searchValue, countryCode, false); - }, [searchTerm, availableOptions.userToInvite, assignees.length, countryCode, translate]); + }, [debouncedSearchTerm, availableOptions.userToInvite, assignees.length, countryCode, translate]); const textInputOptions = useMemo( () => ({ diff --git a/tests/unit/hooks/useDebouncedState.test.ts b/tests/unit/hooks/useDebouncedState.test.ts new file mode 100644 index 000000000000..10709e8d50e2 --- /dev/null +++ b/tests/unit/hooks/useDebouncedState.test.ts @@ -0,0 +1,86 @@ +import {act, renderHook} from '@testing-library/react-native'; +import useDebouncedState from '@hooks/useDebouncedState'; +import CONST from '@src/CONST'; + +describe('useDebouncedState', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should update immediate value synchronously while debounced value waits', () => { + const {result} = renderHook(() => useDebouncedState('')); + + act(() => { + result.current[2]('john'); + }); + + expect(result.current[0]).toBe('john'); + expect(result.current[1]).toBe(''); + }); + + it('should update debounced value after delay elapses', () => { + const {result} = renderHook(() => useDebouncedState('')); + + act(() => { + result.current[2]('john'); + }); + + act(() => { + jest.advanceTimersByTime(CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + }); + + expect(result.current[1]).toBe('john'); + }); + + it('should debounce rapid changes and only emit final value', () => { + const {result} = renderHook(() => useDebouncedState('')); + + // Simulate rapid typing + act(() => { + result.current[2]('j'); + }); + act(() => { + jest.advanceTimersByTime(50); + result.current[2]('jo'); + }); + act(() => { + jest.advanceTimersByTime(50); + result.current[2]('joh'); + }); + act(() => { + jest.advanceTimersByTime(50); + result.current[2]('john'); + }); + + // Debounced value should still be empty + expect(result.current[1]).toBe(''); + + act(() => { + jest.advanceTimersByTime(CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + }); + + // Only final value should be emitted + expect(result.current[1]).toBe('john'); + }); + + it('should cancel pending updates on unmount', () => { + const {result, unmount} = renderHook(() => useDebouncedState('')); + + act(() => { + result.current[2]('pending'); + }); + + unmount(); + + expect(() => { + act(() => { + jest.advanceTimersByTime(CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + }); + }).not.toThrow(); + }); +});