Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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++});
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/SearchAutocompleteListTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {query: string; timestamp: string}> = {};
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(<SearchRouterWrapper />);
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();
});
});
});
Loading