From 6d23e93ad016a8284f2303ed417a5419d1c8000a Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:47:24 +0200 Subject: [PATCH 1/6] add pagination hook sort alphabetically before paginating add tests --- src/hooks/useFilteredOptions.ts | 3 +- src/hooks/usePaginatedData.ts | 59 +++++ src/libs/OptionsListUtils/index.ts | 23 +- src/pages/NewChatPage/index.tsx | 62 ++++- ...mergeAndSortPersonalDetailsWithContacts.ts | 24 ++ ...ts => useGroupChatDraftParticipantSync.ts} | 22 +- ...eAndSortPersonalDetailsWithContactsTest.ts | 57 +++++ tests/unit/OptionsListUtilsTest.tsx | 27 ++ ...> useGroupChatDraftParticipantSyncTest.ts} | 6 +- tests/unit/usePaginatedDataTest.ts | 231 ++++++++++++++++++ 10 files changed, 479 insertions(+), 35 deletions(-) create mode 100644 src/hooks/usePaginatedData.ts create mode 100644 src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts rename src/pages/NewChatPage/{useGroupDraftRestore.ts => useGroupChatDraftParticipantSync.ts} (88%) create mode 100644 tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts rename tests/unit/{useGroupDraftRestoreTest.ts => useGroupChatDraftParticipantSyncTest.ts} (98%) create mode 100644 tests/unit/usePaginatedDataTest.ts diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index a8d30765cfbe..81dac6072529 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -93,7 +93,8 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO [enabled, allReports, allPersonalDetails, reportAttributesDerived, privateIsArchivedMap, allPolicies, reportsLimit, includeP2P, isSearching, betas], ); - const hasMore = options ? reportsLimit < totalReports : false; + // when isSearching is set to true, the createFilteredOptionList returns all reports + const hasMore = !isSearching && options ? reportsLimit < totalReports : false; const loadMore = () => { if (!hasMore) { diff --git a/src/hooks/usePaginatedData.ts b/src/hooks/usePaginatedData.ts new file mode 100644 index 000000000000..2f0fc1958247 --- /dev/null +++ b/src/hooks/usePaginatedData.ts @@ -0,0 +1,59 @@ +import {useState} from 'react'; + +type UsePaginatedDataConfig = { + /** When this value changes, pagination resets to the first page. */ + resetKey?: string; + + /** When true, returns `data` as-is with `hasMore: false` and a no-op `loadMore`. */ + skipPagination?: boolean; +}; + +/** + * Client-side bounded-slice pagination. + * + * Returns the first `pageSize * currentPage` items of `data`, plus `loadMore` to advance a page and + * `hasMore` to indicate whether more items are available. `currentPage` is reset to 1 whenever + * `resetKey` changes, so callers can tie the page back to a logical context (e.g. a search query). + */ +function usePaginatedData( + data: T[], + pageSize: number, + {resetKey = '', skipPagination = false}: UsePaginatedDataConfig = {}, +): { + paginatedData: T[]; + loadMore: () => void; + hasMore: boolean; +} { + const [prevResetKey, setPrevResetKey] = useState(resetKey); + const [currentPage, setCurrentPage] = useState(1); + + if (resetKey !== prevResetKey) { + setPrevResetKey(resetKey); + setCurrentPage(1); + } + + if (skipPagination) { + return {paginatedData: data, loadMore: () => {}, hasMore: false}; + } + + if (pageSize < 1) { + return {paginatedData: [], loadMore: () => {}, hasMore: false}; + } + + const limit = pageSize * currentPage; + + const paginatedData = data.slice(0, limit); + + const hasMore = data.length > limit; + + const loadMore = () => { + if (!hasMore) { + return; + } + setCurrentPage((prev) => prev + 1); + }; + + return {paginatedData, loadMore, hasMore}; +} + +export default usePaginatedData; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 5b5bd8c77f2d..dd144786be46 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1526,11 +1526,15 @@ const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArc * * Performance optimization approach: * 1. Pre-filters reports using shouldReportBeInOptionList with correct parameters (betas, etc.) - * 2. Sorts by lastVisibleActionCreated (most recent first) - * 3. Limits to top N reports - * 4. Processes only those N reports + * 2. Default (`options.isSearching` false): sorts by lastVisibleActionCreated (most recent first), limits to + * the top N reports (`maxRecentReports`), then processes only those reports. This avoids processing + * thousands of reports while ensuring correct filtering. + * 3. Search mode (`options.isSearching` true): uses the full pre-filtered report list with no recency sort and + * no `maxRecentReports` cap, so search can include all eligible reports. * - * This avoids processing thousands of reports while ensuring correct filtering. + * @param options.isSearching - When true, skips the sort and top-N limit in step 2; when false, applies them. + * + * @remarks In search mode, sorting by last visible action is skipped because the UI needs the full eligible set. * * Use this for screens that need recent reports (NewChatPage, WorkspaceInvitePage, etc.) */ @@ -1666,9 +1670,11 @@ function createOptionFromReport( }; } -function orderPersonalDetailsOptions(options: SearchOptionData[]) { +function orderPersonalDetailsOptions(options: T[]): T[] { // PersonalDetails should be ordered Alphabetically by default - https://github.com/Expensify/App/issues/8220#issuecomment-1104009435 - return lodashOrderBy(options, [(personalDetail) => personalDetail.text?.toLowerCase()], 'asc'); + // Keep this aligned with `getValidOptions` ordering (`optionsOrderBy(..., personalDetailsComparator, ...)`) + // so upstream and downstream sorting use the same key (text -> alternateText -> login). + return lodashOrderBy(options, [personalDetailsComparator], 'asc'); } /** @@ -1682,10 +1688,10 @@ function orderReportOptions(options: SearchOptionData[]) { /** * Sort personal details by displayName or login in alphabetical order */ -const personalDetailsComparator = (personalDetail: SearchOptionData | PersonalDetailOptionData) => { +function personalDetailsComparator(personalDetail: SearchOptionData | PersonalDetailOptionData) { const name = personalDetail.text ?? personalDetail.alternateText ?? personalDetail.login ?? ''; return name.toLowerCase(); -}; +} /** * Sort reports by archived status and last visible action @@ -3461,6 +3467,7 @@ export { isSearchStringMatchUserDetails, optionsOrderBy, orderOptions, + orderPersonalDetailsOptions, orderWorkspaceOptions, processReport, recentReportComparator, diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index 223d7c38167f..00cdb43a8485 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -24,6 +24,7 @@ import useIsFocusedRef from '@hooks/useIsFocusedRef'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePaginatedData from '@hooks/usePaginatedData'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useSingleExecution from '@hooks/useSingleExecution'; import useSortedActions from '@hooks/useSortedActions'; @@ -44,10 +45,12 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import KeyboardUtils from '@src/utils/keyboard'; +import mergeAndSortPersonalDetailsWithContacts from './mergeAndSortPersonalDetailsWithContacts'; import type SelectedOption from './types'; -import useGroupChatDraftParticipantSync from './useGroupDraftRestore'; +import useGroupChatDraftParticipantSync from './useGroupChatDraftParticipantSync'; const excludedGroupEmails = new Set(CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE)); +const PAGINATION_SIZE = CONST.MAX_SELECTION_LIST_PAGE_LENGTH; function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined) { const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -68,10 +71,12 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isSearching = !!debouncedSearchTerm.trim(); + const { options: listOptions, isLoading, - loadMore, + loadMore: loadMoreReports, hasMore, } = useFilteredOptions({ maxRecentReports: 500, @@ -79,7 +84,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor includeP2P: true, batchSize: 100, enablePagination: true, - isSearching: !!debouncedSearchTerm.trim(), + isSearching, betas, }); @@ -87,13 +92,29 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const reports = listOptions?.reports ?? []; - const personalDetails = listOptions?.personalDetails ?? []; - useGroupChatDraftParticipantSync(personalDetails, !isLoading, allPersonalDetails, loginList, currentUserEmail, currentUserAccountID, selectedOptions, setSelectedOptions); + + const allPersonalDetailOptions = listOptions?.personalDetails ?? []; + + // Dedupe and sort the union of Onyx personal details and imported contacts so pagination uses an alphabetical prefix. + const sortedPersonalDetailOptionsWithContacts = mergeAndSortPersonalDetailsWithContacts(allPersonalDetailOptions, contacts); + + // Limits raw personal details entering getValidOptions to reduce processing cost on initial load. + // Bypassed during search to avoid repeatedly calling loadMore and prevent FlashList onEndReached infinite loop. + const { + paginatedData: personalDetails, + loadMore: loadMorePersonalDetails, + hasMore: hasMorePersonalDetails, + } = usePaginatedData(sortedPersonalDetailOptionsWithContacts, PAGINATION_SIZE, { + resetKey: isSearching ? 'searching' : 'notSearching', + skipPagination: isSearching, + }); + + useGroupChatDraftParticipantSync(allPersonalDetailOptions, !isLoading, allPersonalDetails, loginList, currentUserEmail, currentUserAccountID, selectedOptions, setSelectedOptions); const defaultOptions = getValidOptions( { reports, - personalDetails: personalDetails.concat(contacts), + personalDetails, }, allPolicies, draftComments, @@ -114,6 +135,10 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor }, ); + if (selectedOptions.length) { + defaultOptions.recentReports = defaultOptions.recentReports.filter((option) => !option.isSelfDM); + } + const unselectedOptions = filterSelectedOptions(defaultOptions, new Set(selectedOptions.map(({accountID}) => accountID))); const areOptionsInitialized = !isLoading; @@ -125,6 +150,13 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const cleanSearchTerm = debouncedSearchTerm.trim().toLowerCase(); + // Visual pagination — limits how many filtered personal details are passed to FlashList at once. + const { + paginatedData: paginatedFilteredPersonalDetails, + loadMore: loadMoreFilteredPersonalDetails, + hasMore: hasMoreFilteredPersonalDetails, + } = usePaginatedData(options.personalDetails, PAGINATION_SIZE, {resetKey: cleanSearchTerm, skipPagination: !isSearching}); + const headerMessage = getHeaderMessage( options.personalDetails.length + options.recentReports.length !== 0, !!options.userToInvite, @@ -150,14 +182,26 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor }, [debouncedSearchTerm]); const handleEndReached = () => { - if (!hasMore || !areOptionsInitialized || !isScreenFocusedRef.current) { + if ((!hasMoreFilteredPersonalDetails && !hasMorePersonalDetails && !hasMore) || !areOptionsInitialized || !isScreenFocusedRef.current) { return; } - loadMore(); + + if (hasMoreFilteredPersonalDetails) { + loadMoreFilteredPersonalDetails(); + } + + if (hasMorePersonalDetails) { + loadMorePersonalDetails(); + } + + if (options.recentReports.length < CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW && hasMore) { + loadMoreReports(); + } }; return { ...options, + personalDetails: paginatedFilteredPersonalDetails, searchTerm, debouncedSearchTerm, setSearchTerm, @@ -228,7 +272,7 @@ function NewChatPage({ref}: NewChatPageProps) { sections.push({ title: translate('common.recents'), - data: selectedOptions.length ? recentReports.filter((option) => !option.isSelfDM) : recentReports, + data: recentReports, sectionIndex: 1, }); diff --git a/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts b/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts new file mode 100644 index 000000000000..c72487b52c25 --- /dev/null +++ b/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts @@ -0,0 +1,24 @@ +import lodashUniqBy from 'lodash/uniqBy'; +import {orderPersonalDetailsOptions} from '@libs/OptionsListUtils'; +import type {SearchOption} from '@libs/OptionsListUtils'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; +import type {PersonalDetails} from '@src/types/onyx'; + +/** + * Merges Onyx personal details with imported contacts, removes entries without login, + * de-dupes by normalized login (case-insensitive, with SMS-domain normalization), + * and returns the union sorted alphabetically. + * + * Onyx options are placed first in the merge order, so when logins collide + * the Onyx entry is preserved over the imported contact entry. + */ +function mergeAndSortPersonalDetailsWithContacts( + allPersonalDetailOptions: Array>, + contacts: Array>, +): Array> { + const merged = [...allPersonalDetailOptions, ...contacts].filter((option) => !!option.login); + const deduped = lodashUniqBy(merged, (option) => addSMSDomainIfPhoneNumber(option.login).toLowerCase()); + return orderPersonalDetailsOptions(deduped); +} + +export default mergeAndSortPersonalDetailsWithContacts; diff --git a/src/pages/NewChatPage/useGroupDraftRestore.ts b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts similarity index 88% rename from src/pages/NewChatPage/useGroupDraftRestore.ts rename to src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts index 5b740147fb81..13b048fc968c 100644 --- a/src/pages/NewChatPage/useGroupDraftRestore.ts +++ b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts @@ -1,6 +1,6 @@ -import {useFocusEffect} from '@react-navigation/native'; -import {useCallback, useEffect, useEffectEvent, useRef} from 'react'; +import {useEffect, useEffectEvent, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import useIsFocusedRef from '@hooks/useIsFocusedRef'; import useOnyx from '@hooks/useOnyx'; import {getUserToInviteOption} from '@libs/OptionsListUtils'; import type {SearchOption} from '@libs/OptionsListUtils'; @@ -30,10 +30,10 @@ function useGroupChatDraftParticipantSync( setSelectedOptions: (options: SelectedOption[]) => void, ) { const shouldRestoreSelectedOptionsRef = useRef(true); - const isScreenInBackgroundRef = useRef(false); + const isScreenFocusedRef = useIsFocusedRef(); const draftParticipantsSelector = (draft: NewGroupChatDraft | undefined) => { - const isSubscriptionActive = shouldRestoreSelectedOptionsRef.current || isScreenInBackgroundRef.current; + const isSubscriptionActive = shouldRestoreSelectedOptionsRef.current || !isScreenFocusedRef.current; return isSubscriptionActive ? draft?.participants : undefined; }; @@ -81,23 +81,13 @@ function useGroupChatDraftParticipantSync( setSelectedOptions(filteredSelectionOptions); }); - useFocusEffect( - useCallback(() => { - isScreenInBackgroundRef.current = false; - - return () => { - isScreenInBackgroundRef.current = true; - }; - }, []), - ); - // Handle removing participants on other pages (e.g. NewChatConfirmPage) useEffect(() => { - if (!isScreenInBackgroundRef.current) { + if (isScreenFocusedRef.current) { return; } syncSelectedOptionsWithDraft(); - }, [draftParticipants]); + }, [draftParticipants, isScreenFocusedRef]); const areRestoreInputsReady = areAllPersonalDetailOptionsLoaded && !isLoadingOnyxValue(draftParticipantsMetadata); diff --git a/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts b/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts new file mode 100644 index 000000000000..2781eb439b91 --- /dev/null +++ b/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts @@ -0,0 +1,57 @@ +import type {SearchOption} from '@libs/OptionsListUtils'; +import mergeAndSortPersonalDetailsWithContacts from '@pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts'; +import type {PersonalDetails} from '@src/types/onyx'; + +describe('mergeAndSortPersonalDetailsWithContacts', () => { + it('sorts the merged set alphabetically by the personalDetailsComparator key', () => { + const onyx = [ + {login: 'zara@x.com', accountID: 1, text: 'Zara'}, + {login: 'bob@x.com', accountID: 2, text: 'Bob'}, + ] as Array>; + const contacts = [{login: 'aaron@x.com', accountID: 999, text: 'Aaron'}] as Array>; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result.map((option) => option.text)).toEqual(['Aaron', 'Bob', 'Zara']); + }); + + it('dedupes by login case-insensitively', () => { + const onyx = [{login: 'john@x.com', accountID: 1, text: 'John Onyx'}] as Array>; + const contacts = [{login: 'JOHN@x.com', accountID: 999, text: 'John Contact'}] as Array>; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result).toHaveLength(1); + }); + + it('prefers the Onyx personal-detail copy over the contact copy on a login collision', () => { + const onyx = [{login: 'john@x.com', accountID: 12345, text: 'John Onyx'}] as Array>; + const contacts = [{login: 'john@x.com', accountID: 99999, text: 'John Contact'}] as Array>; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result.at(0)?.accountID).toBe(12345); + expect(result.at(0)?.text).toBe('John Onyx'); + }); + + it('dedupes phone-number logins regardless of SMS-domain suffix', () => { + const onyx = [{login: '+12025550100@expensify.sms', accountID: 1, text: 'Phone Onyx'}] as Array>; + const contacts = [{login: '+12025550100', accountID: 999, text: 'Phone Contact'}] as Array>; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result).toHaveLength(1); + expect(result.at(0)?.accountID).toBe(1); + }); + + it('drops entries with empty login', () => { + const onyx = [ + {login: '', accountID: 1, text: 'Anon'}, + {login: 'a@x.com', accountID: 2, text: 'Alice'}, + ] as Array>; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, []); + + expect(result.map((option) => option.login)).toEqual(['a@x.com']); + }); +}); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index f77c95dfad3d..7e1e82da36d0 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -38,6 +38,7 @@ import { getValidOptions, optionsOrderBy, orderOptions, + orderPersonalDetailsOptions, orderWorkspaceOptions, recentReportComparator, shouldShowLastActorDisplayName, @@ -8028,6 +8029,32 @@ describe('OptionsListUtils', () => { }); }); + describe('orderPersonalDetailsOptions()', () => { + it('sorts options alphabetically using text values', () => { + const options = [ + {accountID: 1, text: 'Charlie', login: 'c@example.com'}, + {accountID: 2, text: 'aaron', login: 'a@example.com'}, + {accountID: 3, text: 'Bob', login: 'b@example.com'}, + ] as SearchOptionData[]; + + const sorted = orderPersonalDetailsOptions(options); + + expect(sorted.map((option) => option.text)).toEqual(['aaron', 'Bob', 'Charlie']); + }); + + it('falls back to alternateText and login when text is missing', () => { + const options = [ + {accountID: 1, text: undefined, alternateText: 'mango', login: 'm@example.com'}, + {accountID: 2, text: 'apple', login: 'a@example.com'}, + {accountID: 3, text: undefined, alternateText: undefined, login: 'banana@example.com'}, + ] as SearchOptionData[]; + + const sorted = orderPersonalDetailsOptions(options); + + expect(sorted.map((option) => option.accountID)).toEqual([2, 3, 1]); + }); + }); + describe('getSearchOptions with sortedActions', () => { it('should forward sortedActions through getSearchOptions without errors', async () => { const reportID = 'search-sorted-1'; diff --git a/tests/unit/useGroupDraftRestoreTest.ts b/tests/unit/useGroupChatDraftParticipantSyncTest.ts similarity index 98% rename from tests/unit/useGroupDraftRestoreTest.ts rename to tests/unit/useGroupChatDraftParticipantSyncTest.ts index ba2252454b19..621391ecd010 100644 --- a/tests/unit/useGroupDraftRestoreTest.ts +++ b/tests/unit/useGroupChatDraftParticipantSyncTest.ts @@ -7,7 +7,7 @@ import type {SearchOption} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import type {PersonalDetails} from '@src/types/onyx'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; -import useGroupChatDraftParticipantSync from '../../src/pages/NewChatPage/useGroupDraftRestore'; +import useGroupChatDraftParticipantSync from '../../src/pages/NewChatPage/useGroupChatDraftParticipantSync'; const mockUseOnyx = useOnyx as jest.MockedFunction; const mockGetUserToInviteOption = OptionsListUtilsModule.getUserToInviteOption as jest.MockedFunction; @@ -35,6 +35,10 @@ jest.mock('@react-navigation/native', () => { mockFocusState.cleanup = cleanup; } }, + useNavigation: () => ({ + isFocused: () => mockFocusState.isScreenFocused, + addListener: () => () => {}, + }), }; }); diff --git a/tests/unit/usePaginatedDataTest.ts b/tests/unit/usePaginatedDataTest.ts new file mode 100644 index 000000000000..c8b5f7664d53 --- /dev/null +++ b/tests/unit/usePaginatedDataTest.ts @@ -0,0 +1,231 @@ +import {act, renderHook} from '@testing-library/react-native'; +import usePaginatedData from '@hooks/usePaginatedData'; + +const buildData = (size: number) => Array.from({length: size}, (_, index) => index); + +describe('usePaginatedData', () => { + describe('initial render', () => { + it('returns the first pageSize items on first render', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + }); + + it('reports hasMore=true when data exceeds the first page', () => { + const {result} = renderHook(() => usePaginatedData(buildData(50), 10)); + + expect(result.current.hasMore).toBe(true); + }); + + it('reports hasMore=false when data length equals pageSize exactly (boundary)', () => { + const {result} = renderHook(() => usePaginatedData(buildData(10), 10)); + + expect(result.current.hasMore).toBe(false); + }); + + it('returns empty paginatedData with hasMore=false when data is empty', () => { + const {result} = renderHook(() => usePaginatedData([], 10)); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + }); + + describe('loadMore semantics', () => { + it('advances paginatedData by exactly one pageSize', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('can be called repeatedly to walk through all pages', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data); + expect(result.current.hasMore).toBe(false); + }); + + it('is a no-op once hasMore is false', () => { + const data = buildData(20); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data); + expect(result.current.hasMore).toBe(false); + }); + + it('flips hasMore to false on the same render that exactly consumes the data', () => { + const data = buildData(20); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + expect(result.current.hasMore).toBe(true); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data); + expect(result.current.hasMore).toBe(false); + }); + }); + + describe('resetKey behavior', () => { + it('preserves currentPage when resetKey is unchanged across re-renders', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({resetKey}) => usePaginatedData(data, 10, {resetKey}), {initialProps: {resetKey: 'a'}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({resetKey: 'a'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('resets paginatedData to the first page when resetKey changes', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({resetKey}) => usePaginatedData(data, 10, {resetKey}), {initialProps: {resetKey: 'a'}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({resetKey: 'b'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + expect(result.current.hasMore).toBe(true); + }); + + it('treats an omitted config as stable across renders (default resetKey is stable)', () => { + const data = buildData(50); + const {result, rerender} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender(undefined as never); + + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('returns a non-empty page 1 after a resetKey-driven reset', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({resetKey}) => usePaginatedData(data, 10, {resetKey}), {initialProps: {resetKey: 'a'}}); + + act(() => result.current.loadMore()); + + rerender({resetKey: 'b'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + expect(result.current.paginatedData.length).toBeGreaterThan(0); + }); + }); + + describe('skipPagination behavior', () => { + it('returns the full data array as paginatedData when skipPagination is true', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10, {skipPagination: true})); + + expect(result.current.paginatedData).toEqual(data); + }); + + it('reports hasMore=false when skipPagination is true', () => { + const {result} = renderHook(() => usePaginatedData(buildData(50), 10, {skipPagination: true})); + + expect(result.current.hasMore).toBe(false); + }); + + it('makes loadMore a no-op when skipPagination is true', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({skipPagination}) => usePaginatedData(data, 10, {skipPagination}), {initialProps: {skipPagination: false}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({skipPagination: true}); + expect(result.current.paginatedData).toEqual(data); + + act(() => result.current.loadMore()); + + rerender({skipPagination: false}); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('preserves currentPage when skipPagination toggles on then off (resetKey unchanged)', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({skipPagination}) => usePaginatedData(data, 10, {skipPagination}), {initialProps: {skipPagination: false}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({skipPagination: true}); + expect(result.current.paginatedData).toEqual(data); + + rerender({skipPagination: false}); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('does not block a new resetKey from registering while skipPagination is true', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({skipPagination, resetKey}) => usePaginatedData(data, 10, {resetKey, skipPagination}), { + initialProps: {skipPagination: false, resetKey: 'a'}, + }); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({skipPagination: true, resetKey: 'b'}); + expect(result.current.paginatedData).toEqual(data); + + rerender({skipPagination: false, resetKey: 'a'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + expect(result.current.hasMore).toBe(true); + }); + }); + + describe('pageSize edge cases', () => { + it('returns an empty array with hasMore=false and a no-op loadMore when pageSize=0', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 0)); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + + it('treats negative pageSize the same as pageSize=0', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, -5)); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + }); +}); From 7fc615a9855abd9705b5ea616869959cb15e31b2 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:44:35 +0200 Subject: [PATCH 2/6] Remove arbitrary stings, add clarifying comments --- src/pages/NewChatPage/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index b0026f99d3df..2101b1716910 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -96,6 +96,10 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor // Dedupe and sort the union of Onyx personal details and imported contacts so pagination uses an alphabetical prefix. const sortedPersonalDetailOptionsWithContacts = mergeAndSortPersonalDetailsWithContacts(allPersonalDetailOptions, contacts); + // resetKey is typed string and resets pagination to page 1 when it changes. We want that only + // on the search-mode flip (not per keystroke), so it mirrors isSearching rather than the term. + const browsePaginationResetKey = String(isSearching); + // Limits raw personal details entering getValidOptions to reduce processing cost on initial load. // Bypassed during search to avoid repeatedly calling loadMore and prevent FlashList onEndReached infinite loop. const { @@ -103,7 +107,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor loadMore: loadMorePersonalDetails, hasMore: hasMorePersonalDetails, } = usePaginatedData(sortedPersonalDetailOptionsWithContacts, PAGINATION_SIZE, { - resetKey: isSearching ? 'searching' : 'notSearching', + resetKey: browsePaginationResetKey, skipPagination: isSearching, }); From 8d30a5e66dc7ac8b2aa3a29548cbac5d98019051 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:39:41 +0200 Subject: [PATCH 3/6] Refactor: change var names --- src/components/SelectionList/components/TextInput.tsx | 4 ++-- src/pages/NewChatPage/index.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 40ddfc918ff7..b374e5293e8d 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -90,8 +90,8 @@ function TextInput({ const noResultsFoundText = translate('common.noResultsFound'); const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const isScreenReaderEnabled = Accessibility.useScreenReaderStatus(); - const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; - const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); + const hasNoData = dataLength === 0 && !shouldShowLoadingPlaceholder; + const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || hasNoData); const trimmedSearchValue = value?.trim() ?? ''; const suggestionsCount = dataLength ?? 0; const suggestionsAnnouncement = diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index 2101b1716910..0ae51490679e 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -76,7 +76,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor options: listOptions, isLoading, loadMore: loadMoreReports, - hasMore, + hasMore: hasMoreFilteredOptions, } = useFilteredOptions({ maxRecentReports: 500, enabled: didScreenTransitionEnd, @@ -183,7 +183,8 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor }, [debouncedSearchTerm]); const handleEndReached = () => { - if ((!hasMoreFilteredPersonalDetails && !hasMorePersonalDetails && !hasMore) || !areOptionsInitialized || !isScreenFocusedRef.current) { + const hasNoDataToLoad = !hasMoreFilteredPersonalDetails && !hasMorePersonalDetails && !hasMoreFilteredOptions; + if (hasNoDataToLoad || !areOptionsInitialized || !isScreenFocusedRef.current) { return; } @@ -195,7 +196,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor loadMorePersonalDetails(); } - if (options.recentReports.length < CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW && hasMore) { + if (options.recentReports.length < CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW && hasMoreFilteredOptions) { loadMoreReports(); } }; From 18013246f5a20a31c420afcb30507b19040e173c Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:15:59 +0200 Subject: [PATCH 4/6] Apply reviewer suggestions --- src/hooks/useFilteredOptions.ts | 2 +- src/hooks/usePaginatedData.ts | 2 -- src/pages/NewChatPage/index.tsx | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index 81dac6072529..2b4973b3d16c 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -93,7 +93,7 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO [enabled, allReports, allPersonalDetails, reportAttributesDerived, privateIsArchivedMap, allPolicies, reportsLimit, includeP2P, isSearching, betas], ); - // when isSearching is set to true, the createFilteredOptionList returns all reports + // When isSearching is set to true, the createFilteredOptionList returns all reports const hasMore = !isSearching && options ? reportsLimit < totalReports : false; const loadMore = () => { diff --git a/src/hooks/usePaginatedData.ts b/src/hooks/usePaginatedData.ts index 2f0fc1958247..b7a408d85a6e 100644 --- a/src/hooks/usePaginatedData.ts +++ b/src/hooks/usePaginatedData.ts @@ -41,9 +41,7 @@ function usePaginatedData( } const limit = pageSize * currentPage; - const paginatedData = data.slice(0, limit); - const hasMore = data.length > limit; const loadMore = () => { diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index 0ae51490679e..2aee965c4ed7 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -96,8 +96,8 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor // Dedupe and sort the union of Onyx personal details and imported contacts so pagination uses an alphabetical prefix. const sortedPersonalDetailOptionsWithContacts = mergeAndSortPersonalDetailsWithContacts(allPersonalDetailOptions, contacts); - // resetKey is typed string and resets pagination to page 1 when it changes. We want that only - // on the search-mode flip (not per keystroke), so it mirrors isSearching rather than the term. + // usePaginatedData resets to page 1 whenever resetKey changes. Encode browse and search as "false" and "true", respectively, + // so that we only reset when the user enters or leaves search, not on every debounced keystroke. const browsePaginationResetKey = String(isSearching); // Limits raw personal details entering getValidOptions to reduce processing cost on initial load. From c21b360c75386616414c75d567be15b2ebd8660e Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:56:38 +0200 Subject: [PATCH 5/6] Fix lint --- src/libs/ReportUtils.ts | 10 ++-- ...mergeAndSortPersonalDetailsWithContacts.ts | 8 +-- ...eAndSortPersonalDetailsWithContactsTest.ts | 33 ++++++------ tests/unit/OptionsListUtilsTest.tsx | 4 +- .../useGroupChatDraftParticipantSyncTest.ts | 53 ++++++++++--------- tests/unit/usePaginatedDataTest.ts | 2 +- 6 files changed, 54 insertions(+), 56 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index be2521f7d595..5e3de6461a59 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -816,7 +816,10 @@ type CustomIcon = { color?: string; }; -type OptionData = { +type OptionData = Report & + Omit & { + private_isArchived?: boolean; + } & { text?: string; alternateText?: string; allReportErrors?: Errors; @@ -882,10 +885,7 @@ type OptionData = { lastName?: string; avatar?: AvatarSource; timezone?: Timezone; -} & Report & - Omit & { - private_isArchived?: boolean; - }; +}; type OnyxDataTaskAssigneeChat = { optimisticData: Array>; diff --git a/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts b/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts index c72487b52c25..2162eeb92c8b 100644 --- a/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts +++ b/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts @@ -1,8 +1,7 @@ import lodashUniqBy from 'lodash/uniqBy'; import {orderPersonalDetailsOptions} from '@libs/OptionsListUtils'; -import type {SearchOption} from '@libs/OptionsListUtils'; +import type {SearchOptionData} from '@libs/OptionsListUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; -import type {PersonalDetails} from '@src/types/onyx'; /** * Merges Onyx personal details with imported contacts, removes entries without login, @@ -12,10 +11,7 @@ import type {PersonalDetails} from '@src/types/onyx'; * Onyx options are placed first in the merge order, so when logins collide * the Onyx entry is preserved over the imported contact entry. */ -function mergeAndSortPersonalDetailsWithContacts( - allPersonalDetailOptions: Array>, - contacts: Array>, -): Array> { +function mergeAndSortPersonalDetailsWithContacts(allPersonalDetailOptions: T[], contacts: T[]): T[] { const merged = [...allPersonalDetailOptions, ...contacts].filter((option) => !!option.login); const deduped = lodashUniqBy(merged, (option) => addSMSDomainIfPhoneNumber(option.login).toLowerCase()); return orderPersonalDetailsOptions(deduped); diff --git a/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts b/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts index 2781eb439b91..adc165ed72c4 100644 --- a/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts +++ b/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts @@ -1,14 +1,13 @@ -import type {SearchOption} from '@libs/OptionsListUtils'; +import type {SearchOptionData} from '@libs/OptionsListUtils'; import mergeAndSortPersonalDetailsWithContacts from '@pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts'; -import type {PersonalDetails} from '@src/types/onyx'; describe('mergeAndSortPersonalDetailsWithContacts', () => { it('sorts the merged set alphabetically by the personalDetailsComparator key', () => { - const onyx = [ - {login: 'zara@x.com', accountID: 1, text: 'Zara'}, - {login: 'bob@x.com', accountID: 2, text: 'Bob'}, - ] as Array>; - const contacts = [{login: 'aaron@x.com', accountID: 999, text: 'Aaron'}] as Array>; + const onyx: SearchOptionData[] = [ + {login: 'zara@x.com', accountID: 1, text: 'Zara', keyForList: '1', reportID: ''}, + {login: 'bob@x.com', accountID: 2, text: 'Bob', keyForList: '2', reportID: ''}, + ]; + const contacts: SearchOptionData[] = [{login: 'aaron@x.com', accountID: 999, text: 'Aaron', keyForList: '999', reportID: ''}]; const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); @@ -16,8 +15,8 @@ describe('mergeAndSortPersonalDetailsWithContacts', () => { }); it('dedupes by login case-insensitively', () => { - const onyx = [{login: 'john@x.com', accountID: 1, text: 'John Onyx'}] as Array>; - const contacts = [{login: 'JOHN@x.com', accountID: 999, text: 'John Contact'}] as Array>; + const onyx: SearchOptionData[] = [{login: 'john@x.com', accountID: 1, text: 'John Onyx', keyForList: '1', reportID: ''}]; + const contacts: SearchOptionData[] = [{login: 'JOHN@x.com', accountID: 999, text: 'John Contact', keyForList: '999', reportID: ''}]; const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); @@ -25,8 +24,8 @@ describe('mergeAndSortPersonalDetailsWithContacts', () => { }); it('prefers the Onyx personal-detail copy over the contact copy on a login collision', () => { - const onyx = [{login: 'john@x.com', accountID: 12345, text: 'John Onyx'}] as Array>; - const contacts = [{login: 'john@x.com', accountID: 99999, text: 'John Contact'}] as Array>; + const onyx: SearchOptionData[] = [{login: 'john@x.com', accountID: 12345, text: 'John Onyx', keyForList: '12345', reportID: ''}]; + const contacts: SearchOptionData[] = [{login: 'john@x.com', accountID: 99999, text: 'John Contact', keyForList: '99999', reportID: ''}]; const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); @@ -35,8 +34,8 @@ describe('mergeAndSortPersonalDetailsWithContacts', () => { }); it('dedupes phone-number logins regardless of SMS-domain suffix', () => { - const onyx = [{login: '+12025550100@expensify.sms', accountID: 1, text: 'Phone Onyx'}] as Array>; - const contacts = [{login: '+12025550100', accountID: 999, text: 'Phone Contact'}] as Array>; + const onyx: SearchOptionData[] = [{login: '+12025550100@expensify.sms', accountID: 1, text: 'Phone Onyx', keyForList: '1', reportID: ''}]; + const contacts: SearchOptionData[] = [{login: '+12025550100', accountID: 999, text: 'Phone Contact', keyForList: '999', reportID: ''}]; const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); @@ -45,10 +44,10 @@ describe('mergeAndSortPersonalDetailsWithContacts', () => { }); it('drops entries with empty login', () => { - const onyx = [ - {login: '', accountID: 1, text: 'Anon'}, - {login: 'a@x.com', accountID: 2, text: 'Alice'}, - ] as Array>; + const onyx: SearchOptionData[] = [ + {login: '', accountID: 1, text: 'Anon', keyForList: '1', reportID: ''}, + {login: 'a@x.com', accountID: 2, text: 'Alice', keyForList: '2', reportID: ''}, + ]; const result = mergeAndSortPersonalDetailsWithContacts(onyx, []); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index dcb10a06e5e1..07d3d10cd083 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3761,8 +3761,8 @@ describe('OptionsListUtils', () => { it('handles negative limit with large absolute value', () => { const options: OptionData[] = [ - {reportID: '1', lastVisibleActionCreated: '2022-01-01T10:00:00Z'} as OptionData, - {reportID: '2', lastVisibleActionCreated: '2022-01-01T12:00:00Z'} as OptionData, + {reportID: '1', lastVisibleActionCreated: '2022-01-01T10:00:00Z', keyForList: '1'}, + {reportID: '2', lastVisibleActionCreated: '2022-01-01T12:00:00Z', keyForList: '2'}, ]; const comparator = (option: OptionData) => option.lastVisibleActionCreated ?? ''; const result = optionsOrderBy(options, comparator, -100).options; diff --git a/tests/unit/useGroupChatDraftParticipantSyncTest.ts b/tests/unit/useGroupChatDraftParticipantSyncTest.ts index a35182f312d1..86a730e8592e 100644 --- a/tests/unit/useGroupChatDraftParticipantSyncTest.ts +++ b/tests/unit/useGroupChatDraftParticipantSyncTest.ts @@ -6,10 +6,11 @@ import type {SearchOption} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import type {PersonalDetails} from '@src/types/onyx'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; +import type SelectedOption from '../../src/pages/NewChatPage/types'; import useGroupChatDraftParticipantSync from '../../src/pages/NewChatPage/useGroupChatDraftParticipantSync'; -const mockUseOnyx = useOnyx as jest.MockedFunction; -const mockGetUserToInviteOption = OptionsListUtilsModule.getUserToInviteOption as jest.MockedFunction; +const mockUseOnyx: jest.Mock = jest.mocked(useOnyx); +const mockGetUserToInviteOption = jest.mocked(OptionsListUtilsModule.getUserToInviteOption); jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@libs/OptionsListUtils', () => { @@ -44,24 +45,26 @@ jest.mock('@react-navigation/native', () => { const CURRENT_USER_ACCOUNT_ID = 1; const CURRENT_USER_EMAIL = 'current@test.com'; -function makePersonalDetailOption(accountID: number, login: string) { +function makePersonalDetailOption(accountID: number, login: string): SearchOption { return { accountID, login, text: login, keyForList: String(accountID), - item: {accountID, login, displayName: login} as PersonalDetails, - } as SearchOption; + reportID: '', + item: {accountID, login, displayName: login}, + }; } -function makeSelectedOption(accountID: number, login: string) { +function makeSelectedOption(accountID: number, login: string): OptionData { return { accountID, login, text: login, keyForList: String(accountID), + reportID: '', isSelected: true, - } as OptionData; + }; } const PARTICIPANT_A: SelectedParticipant = {accountID: 10, login: 'alice@test.com'}; @@ -84,14 +87,14 @@ describe('useGroupDraftRestore', () => { }); function setupUseOnyx(draftParticipants: SelectedParticipant[] | undefined, draftStatus: 'loading' | 'loaded' = 'loaded') { - mockUseOnyx.mockImplementation(((key: string, options?: {selector?: (draft: unknown) => unknown}) => { + mockUseOnyx.mockImplementation((key: string, options?: {selector?: (draft: unknown) => unknown}) => { if (key === 'newGroupChatDraft') { const draft = draftParticipants ? {participants: draftParticipants} : undefined; const value = options?.selector ? options.selector(draft) : draft; return [value, {status: draftStatus}]; } return [undefined, {status: 'loaded'}]; - }) as typeof useOnyx); + }); } function renderRestoreHook(overrides?: { @@ -101,7 +104,7 @@ describe('useGroupDraftRestore', () => { draftParticipants?: SelectedParticipant[] | undefined; draftStatus?: 'loading' | 'loaded'; }) { - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const { allPersonalDetailOptions = ALL_PERSONAL_DETAIL_OPTIONS, areAllPersonalDetailOptionsLoaded = true, @@ -134,7 +137,7 @@ describe('useGroupDraftRestore', () => { const {setSelectedOptions} = renderRestoreHook({draftParticipants}); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(2); expect(restored.at(0)?.accountID).toBe(PARTICIPANT_A.accountID); expect(restored.at(1)?.accountID).toBe(PARTICIPANT_B.accountID); @@ -145,7 +148,7 @@ describe('useGroupDraftRestore', () => { const draftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A]; const {setSelectedOptions} = renderRestoreHook({draftParticipants}); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(1); expect(restored.at(0)?.accountID).toBe(PARTICIPANT_A.accountID); }); @@ -170,7 +173,7 @@ describe('useGroupDraftRestore', () => { const draftParticipants = [PARTICIPANT_A]; setupUseOnyx(draftParticipants); - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const {rerender} = renderHook(() => useGroupChatDraftParticipantSync(ALL_PERSONAL_DETAIL_OPTIONS, true, {}, {}, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, [], setSelectedOptions), ); @@ -197,7 +200,7 @@ describe('useGroupDraftRestore', () => { describe('background sync', () => { it('should sync removals when draft changes while screen is in background', () => { const initialDraftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A, PARTICIPANT_B, PARTICIPANT_C]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const selectedAfterRestore = [makeSelectedOption(10, 'alice@test.com'), makeSelectedOption(20, 'bob@test.com'), makeSelectedOption(30, 'carol@test.com')]; setupUseOnyx(initialDraftParticipants); @@ -228,7 +231,7 @@ describe('useGroupDraftRestore', () => { rerender({draftParticipants: draftWithRemoval}); expect(setSelectedOptions).toHaveBeenCalled(); - const synced = (setSelectedOptions.mock.calls as OptionData[][][]).at(-1)?.at(0) ?? []; + const synced = setSelectedOptions.mock.calls.at(-1)?.at(0) ?? []; const syncedLogins = synced.map((option) => option.login); expect(syncedLogins).toContain('alice@test.com'); expect(syncedLogins).toContain('carol@test.com'); @@ -242,7 +245,7 @@ describe('useGroupDraftRestore', () => { it('should sync to empty when all participants are removed from draft', () => { const initialDraftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A, PARTICIPANT_B]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const selectedAfterRestore = [makeSelectedOption(10, 'alice@test.com'), makeSelectedOption(20, 'bob@test.com')]; setupUseOnyx(initialDraftParticipants); @@ -269,13 +272,13 @@ describe('useGroupDraftRestore', () => { rerender({draftParticipants: [CURRENT_USER_PARTICIPANT]}); expect(setSelectedOptions).toHaveBeenCalled(); - const synced = (setSelectedOptions.mock.calls as OptionData[][][]).at(-1)?.at(0) ?? []; + const synced = setSelectedOptions.mock.calls.at(-1)?.at(0) ?? []; expect(synced).toHaveLength(0); }); it('should not sync when screen is focused (normal operation after restore)', () => { const draftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A, PARTICIPANT_B]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const selectedAfterRestore = [makeSelectedOption(10, 'alice@test.com'), makeSelectedOption(20, 'bob@test.com')]; setupUseOnyx(draftParticipants); @@ -304,7 +307,7 @@ describe('useGroupDraftRestore', () => { describe('selector inactivity', () => { it('should not react to draft changes after restore (selector returns undefined)', () => { const draftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); setupUseOnyx(draftParticipants); @@ -330,7 +333,7 @@ describe('useGroupDraftRestore', () => { describe('fallback and edge cases', () => { it('should use getUserToInviteOption fallback for unknown participants', () => { const unknownParticipant: SelectedParticipant = {accountID: 999, login: 'unknown@test.com'}; - const fallbackOption = {text: 'unknown@test.com', login: 'unknown@test.com', accountID: 999, keyForList: '999'} as OptionData; + const fallbackOption: OptionData = {text: 'unknown@test.com', login: 'unknown@test.com', accountID: 999, keyForList: '999', reportID: ''}; mockGetUserToInviteOption.mockReturnValue(fallbackOption); const {setSelectedOptions} = renderRestoreHook({ @@ -338,7 +341,7 @@ describe('useGroupDraftRestore', () => { }); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(1); expect(restored.at(0)?.login).toBe('unknown@test.com'); expect(mockGetUserToInviteOption).toHaveBeenCalled(); @@ -354,7 +357,7 @@ describe('useGroupDraftRestore', () => { it('should restore after personal details load (delayed loading)', () => { const draftParticipants = [PARTICIPANT_A, PARTICIPANT_B]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); setupUseOnyx(draftParticipants); @@ -368,13 +371,13 @@ describe('useGroupDraftRestore', () => { rerender({areLoaded: true}); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(2); }); it('should restore invited-by-email participant when user has no contacts', () => { const invited: SelectedParticipant = {accountID: 999, login: 'invited@test.com'}; - const inviteStub = {text: 'invited@test.com', login: 'invited@test.com', accountID: 999, keyForList: '999'} as OptionData; + const inviteStub: OptionData = {text: 'invited@test.com', login: 'invited@test.com', accountID: 999, keyForList: '999', reportID: ''}; mockGetUserToInviteOption.mockReturnValue(inviteStub); const {setSelectedOptions} = renderRestoreHook({ @@ -384,7 +387,7 @@ describe('useGroupDraftRestore', () => { }); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(1); expect(restored.at(0)?.login).toBe('invited@test.com'); expect(mockGetUserToInviteOption).toHaveBeenCalled(); diff --git a/tests/unit/usePaginatedDataTest.ts b/tests/unit/usePaginatedDataTest.ts index c8b5f7664d53..d1e0200dd791 100644 --- a/tests/unit/usePaginatedDataTest.ts +++ b/tests/unit/usePaginatedDataTest.ts @@ -120,7 +120,7 @@ describe('usePaginatedData', () => { act(() => result.current.loadMore()); expect(result.current.paginatedData).toEqual(data.slice(0, 20)); - rerender(undefined as never); + rerender(undefined); expect(result.current.paginatedData).toEqual(data.slice(0, 20)); }); From a48299e92db0e053c732d5c15aedc4a9a8076b36 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:59:21 +0200 Subject: [PATCH 6/6] Fix prettier --- src/libs/ReportUtils.ts | 132 ++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5e3de6461a59..0ef71920d390 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -820,72 +820,72 @@ type OptionData = Report & Omit & { private_isArchived?: boolean; } & { - text?: string; - alternateText?: string; - allReportErrors?: Errors; - brickRoadIndicator?: ValueOf | '' | null; - actionBadge?: ValueOf; - actionTargetReportActionID?: string; - tooltipText?: string | null; - alternateTextMaxLines?: number; - boldStyle?: boolean; - customIcon?: CustomIcon; - subtitle?: string; - login?: string; - accountID?: number; - pronouns?: string; - status?: Status | null; - phoneNumber?: string; - isUnread?: boolean | null; - isUnreadWithMention?: boolean | null; - hasDraftComment?: boolean | null; - keyForList: string; - searchText?: string; - isIOUReportOwner?: boolean | null; - shouldShowSubscript?: boolean | null; - isPolicyExpenseChat?: boolean; - isMoneyRequestReport?: boolean | null; - isInvoiceReport?: boolean; - isExpenseRequest?: boolean | null; - isAllowedToComment?: boolean | null; - isThread?: boolean | null; - isTaskReport?: boolean | null; - parentReportAction?: OnyxEntry; - displayNamesWithTooltips?: DisplayNameWithTooltips | null; - isDefaultRoom?: boolean; - isInvoiceRoom?: boolean; - isExpenseReport?: boolean; - isDM?: boolean; - isOptimisticPersonalDetail?: boolean; - selected?: boolean; - isOptimisticAccount?: boolean; - isSelected?: boolean; - descriptiveText?: string; - notificationPreference?: NotificationPreference | null; - isDisabled?: boolean | null; - name?: string | null; - isSelfDM?: boolean; - isOneOnOneChat?: boolean; - reportID?: string; - enabled?: boolean; - code?: string; - transactionThreadReportID?: string | null; - shouldShowAmountInput?: boolean; - amountInputProps?: MoneyRequestAmountInputProps; - tabIndex?: 0 | -1; - isConciergeChat?: boolean; - isBold?: boolean; - lastIOUCreationDate?: string; - isChatRoom?: boolean; - participantsList?: PersonalDetails[]; - icons?: Icon[]; - iouReportAmount?: number; - displayName?: string; - firstName?: string; - lastName?: string; - avatar?: AvatarSource; - timezone?: Timezone; -}; + text?: string; + alternateText?: string; + allReportErrors?: Errors; + brickRoadIndicator?: ValueOf | '' | null; + actionBadge?: ValueOf; + actionTargetReportActionID?: string; + tooltipText?: string | null; + alternateTextMaxLines?: number; + boldStyle?: boolean; + customIcon?: CustomIcon; + subtitle?: string; + login?: string; + accountID?: number; + pronouns?: string; + status?: Status | null; + phoneNumber?: string; + isUnread?: boolean | null; + isUnreadWithMention?: boolean | null; + hasDraftComment?: boolean | null; + keyForList: string; + searchText?: string; + isIOUReportOwner?: boolean | null; + shouldShowSubscript?: boolean | null; + isPolicyExpenseChat?: boolean; + isMoneyRequestReport?: boolean | null; + isInvoiceReport?: boolean; + isExpenseRequest?: boolean | null; + isAllowedToComment?: boolean | null; + isThread?: boolean | null; + isTaskReport?: boolean | null; + parentReportAction?: OnyxEntry; + displayNamesWithTooltips?: DisplayNameWithTooltips | null; + isDefaultRoom?: boolean; + isInvoiceRoom?: boolean; + isExpenseReport?: boolean; + isDM?: boolean; + isOptimisticPersonalDetail?: boolean; + selected?: boolean; + isOptimisticAccount?: boolean; + isSelected?: boolean; + descriptiveText?: string; + notificationPreference?: NotificationPreference | null; + isDisabled?: boolean | null; + name?: string | null; + isSelfDM?: boolean; + isOneOnOneChat?: boolean; + reportID?: string; + enabled?: boolean; + code?: string; + transactionThreadReportID?: string | null; + shouldShowAmountInput?: boolean; + amountInputProps?: MoneyRequestAmountInputProps; + tabIndex?: 0 | -1; + isConciergeChat?: boolean; + isBold?: boolean; + lastIOUCreationDate?: string; + isChatRoom?: boolean; + participantsList?: PersonalDetails[]; + icons?: Icon[]; + iouReportAmount?: number; + displayName?: string; + firstName?: string; + lastName?: string; + avatar?: AvatarSource; + timezone?: Timezone; + }; type OnyxDataTaskAssigneeChat = { optimisticData: Array>;