diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 67c1acc4b16f..7a263fb9207c 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -503,15 +503,19 @@ function SearchAutocompleteList({ const localRows: AutocompleteListItem[] = []; const serverRows: AutocompleteListItem[] = []; for (const item of nextStyledRecentReports) { - if (item.keyForList && frozenLocalRank.has(item.keyForList)) { + // Optimistic (invite) accounts are generated locally and are never server search results, + // so always keep them in the local section. Otherwise they briefly land in "Search results" + // before the frozen rank snapshot catches up, flashing across sections. + if (item.isOptimisticAccount || (item.keyForList && frozenLocalRank.has(item.keyForList))) { localRows.push(item); } else { serverRows.push(item); } } // Sort the local section by the rank captured at query-change time so it cannot - // reorder when the API returns. - localRows.sort((a, b) => (frozenLocalRank.get(a.keyForList ?? '') ?? 0) - (frozenLocalRank.get(b.keyForList ?? '') ?? 0)); + // reorder when the API returns. Rows without a frozen rank (e.g. optimistic invite + // accounts) keep their original order at the end of the section. + localRows.sort((a, b) => (frozenLocalRank.get(a.keyForList ?? '') ?? Number.MAX_SAFE_INTEGER) - (frozenLocalRank.get(b.keyForList ?? '') ?? Number.MAX_SAFE_INTEGER)); if (localRows.length > 0 || !isLoadingOptions) { pushSection({title: translate('search.recentChats'), data: localRows, sectionIndex: sectionIndex++}); diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index 16b77aa90ce3..d850466df63c 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -419,5 +419,65 @@ describe('SearchAutocompleteList', () => { // NewServer should appear after the local results (in the server section) expect(relevantOrder.indexOf('Charlie Report')).toBeLessThan(relevantOrder.indexOf('NewServer Report')); }); + + it('should keep an optimistic (invite) account in "Recent chats" instead of "Search results"', async () => { + const recentSearches: Record = {}; + recentSearches['2024-01-01T00:00:00'] = {query: 'type:expense', timestamp: '2024-01-01T00:00:00'}; + + await waitForBatchedUpdates(); + await Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + [ONYXKEYS.RECENT_SEARCHES]: recentSearches, + }); + + render(); + await flushAllUpdates(); + + await waitFor(() => { + expect(screen.getByText('Recent chats')).toBeTruthy(); + }); + + // Type a query that matches the existing reports so their local rank is frozen and non-empty + // (the invite account is not part of that snapshot). + const textInput = screen.getByTestId('search-autocomplete-text-input'); + fireEvent.changeText(textInput, 'test'); + await flushAllUpdates(); + + // Simulate an optimistic (invite) result arriving after the frozen rank was captured. It is not part + // of the frozen snapshot, so without special handling it would be treated as a server result and + // shown under "Search results". Because it is locally generated it must stay in "Recent chats". + getSearchOptionsSpy.mockReturnValue({ + options: { + recentReports: [ + {reportID: '103', keyForList: '103', text: 'Charlie Report', alternateText: 'charlie alt', lastMessageText: 'hey'}, + {reportID: '101', keyForList: '101', text: 'Alice Report', alternateText: 'alice alt', lastMessageText: 'hello'}, + {reportID: '102', keyForList: '102', text: 'Bob Report', alternateText: 'bob alt', lastMessageText: 'hi'}, + {reportID: '201', keyForList: '201', text: 'InviteAccount Report', alternateText: 'invite alt', lastMessageText: 'new', isOptimisticAccount: true}, + ], + personalDetails: [], + currentUserOption: null, + userToInvite: null, + }, + }); + mockUseFilteredOptions.mockReturnValue({ + options: {...mockedOptions}, + isLoading: false, + loadMore: jest.fn(), + hasMore: false, + isLoadingMore: false, + }); + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false); + }); + await flushAllUpdates(); + + // The optimistic account is locally generated, so it must never be treated as a server result. + await waitFor(() => { + expect(screen.getByText('InviteAccount Report')).toBeTruthy(); + }); + expect(screen.queryByText('Search results')).toBeNull(); + }); }); });