diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index cbeff2cf3b78..8161315b134a 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -41,6 +41,9 @@ const ONYXKEYS = {
/** Boolean flag set whenever we are searching for reports in the server */
RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports',
+ /** Ordered reportIDs from the latest SearchForReports response, used to display server search results in the tier order Auth returned. */
+ RAM_ONLY_SEARCH_RESULT_REPORT_IDS: 'searchResultReportIDs',
+
/** Boolean flag indicating a SignInWithShortLivedAuthToken request is in flight. RAM-only so an interrupted request never persists a stuck `true` to IndexedDB and blocks future reauth attempts. */
RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN: 'isAuthenticatingWithShortLivedToken',
@@ -1568,6 +1571,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string;
[ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string;
[ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean;
+ [ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS]: string[];
[ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN]: boolean;
[ONYXKEYS.LAST_VISITED_PATH]: string | undefined;
[ONYXKEYS.REPORT_LAST_VISIT_TIMES]: OnyxTypes.ReportLastVisitTimes;
diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx
index 270127671392..8bd51df51185 100644
--- a/src/components/Search/SearchAutocompleteList.tsx
+++ b/src/components/Search/SearchAutocompleteList.tsx
@@ -172,6 +172,7 @@ function SearchAutocompleteList({
const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER);
const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT;
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
+ const [searchResultReportIDs] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS);
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const currentUserEmail = currentUserPersonalDetails.email ?? '';
const currentUserAccountID = currentUserPersonalDetails.accountID;
@@ -212,7 +213,8 @@ function SearchAutocompleteList({
isUsedInChatFinder: true,
includeReadOnly: true,
searchQuery: autocompleteQueryValue,
- maxResults: CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS,
+ // With an Auth order, keep every matching report instead of just the 20 most recent, so the Auth-order sort below can't drop a top-ranked but old one.
+ maxResults: searchResultReportIDs && searchResultReportIDs.length > 0 ? listOptions.reports.length : CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS,
includeUserToInvite: true,
includeRecentReports: true,
includeCurrentUser: true,
@@ -244,6 +246,7 @@ function SearchAutocompleteList({
sortedActions,
conciergeReportID,
isTrackIntentUser,
+ searchResultReportIDs,
]);
const [isInitialRender, setIsInitialRender] = useState(true);
@@ -385,8 +388,23 @@ function SearchAutocompleteList({
reportOptions.push(searchOptions.userToInvite);
}
+ // When the server has returned a tier-ranked order for this search, display results in that order
+ // instead of the client-side kind/recency order. Reports absent from the list sort to the end.
+ if (searchResultReportIDs && searchResultReportIDs.length > 0) {
+ const rankByReportID = new Map(searchResultReportIDs.map((reportID, index) => [reportID, index]));
+ const rankOf = (option: OptionData) => {
+ // The selfDM always sorts first when it matches — it's always in Onyx, so it ranks ahead of the server order
+ // (and need not be included in it).
+ if (option.isSelfDM) {
+ return -1;
+ }
+ return option.reportID === undefined ? Number.MAX_SAFE_INTEGER : (rankByReportID.get(option.reportID) ?? Number.MAX_SAFE_INTEGER);
+ };
+ reportOptions.sort((a, b) => rankOf(a) - rankOf(b));
+ }
+
return reportOptions.slice(0, 20);
- }, [autocompleteQueryValue, searchOptions]);
+ }, [autocompleteQueryValue, searchOptions, searchResultReportIDs]);
// Locked rank map (keyForList -> originalIndex) capturing the order of locally-known
// results at the moment the query changes. Recomputed only when the query changes, so server
@@ -504,8 +522,17 @@ function SearchAutocompleteList({
customHeader: skeletonHeader,
});
}
+ } else if (searchResultReportIDs && searchResultReportIDs.length > 0) {
+ // The server returned a tier-ranked order for this query (already applied to recentReportsOptions),
+ // so render a single list in that order rather than splitting into local/server sections — splitting
+ // would group local matches separately and break the global tier ordering.
+ if (nextStyledRecentReports.length > 0 || !isLoadingOptions) {
+ pushSection({title: translate('search.serverResults'), data: nextStyledRecentReports, sectionIndex: sectionIndex++});
+ } else {
+ pushSection({title: undefined, data: [], sectionIndex: sectionIndex++, customHeader: skeletonHeader});
+ }
} else {
- // Active search: split rows into local (frozen order) and server sections.
+ // Active search without a server order yet: split rows into local (frozen order) and server sections.
const localRows: AutocompleteListItem[] = [];
const serverRows: AutocompleteListItem[] = [];
for (const item of nextStyledRecentReports) {
@@ -568,6 +595,7 @@ function SearchAutocompleteList({
recentSearchesData,
searchOptions,
searchQueryItems,
+ searchResultReportIDs,
styles,
translate,
isLoadingOptions,
diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts
index 593da3386411..2dcc2441b686 100644
--- a/src/libs/actions/Report/index.ts
+++ b/src/libs/actions/Report/index.ts
@@ -5585,6 +5585,10 @@ function searchForReports(isOffline: boolean, searchInput: string, policyID?: st
function performServerSearch(searchInput: string, policyID?: string, isUserSearch = false) {
// We are not getting isOffline from components as useEffect change will re-trigger the search on network change
const isOffline = isOfflineNetwork();
+
+ // Clear the previous search's server-provided result order so it isn't applied to this query before a fresh response arrives.
+ Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, []);
+
if (isOffline || !searchInput.trim().length) {
Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false);
return;
diff --git a/src/setup/index.ts b/src/setup/index.ts
index ce1aa4999eb7..348e26807b34 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -65,6 +65,7 @@ export default function () {
ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE,
ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED,
ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS,
+ ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS,
ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN,
ONYXKEYS.RAM_ONLY_WALLET_ONFIDO,
ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE,
diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts
index 16e7cfd9c40b..c6dbb0ec85a1 100644
--- a/tests/actions/ReportTest.ts
+++ b/tests/actions/ReportTest.ts
@@ -4642,6 +4642,17 @@ describe('actions/Report', () => {
const lowerCaseRequest = PersistedRequests.getAll().at(1);
expect(upperCaseRequest?.data?.searchInput).toBe(lowerCaseRequest?.data?.searchInput);
});
+
+ it("clears the previous search's result order so a new query does not reuse it", async () => {
+ await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['1', '2', '3']);
+ await waitForBatchedUpdates();
+
+ Report.searchInServer('new query');
+ await waitForBatchedUpdates();
+
+ const orderedReportIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS);
+ expect(orderedReportIDs).toEqual([]);
+ });
});
describe('searchUserInServer', () => {
diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx
index 5f4cfc2e4973..25f6da0b43a5 100644
--- a/tests/unit/SearchAutocompleteListTest.tsx
+++ b/tests/unit/SearchAutocompleteListTest.tsx
@@ -422,6 +422,150 @@ describe('SearchAutocompleteList', () => {
expect(relevantOrder.indexOf('Charlie Report')).toBeLessThan(relevantOrder.indexOf('NewServer Report'));
});
+ it('should order all search results by the order Auth returned', async () => {
+ await waitForBatchedUpdates();
+ await Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ });
+
+ render();
+ await flushAllUpdates();
+
+ // Type a search query to freeze the local rank for the locally-known reports.
+ const textInput = screen.getByTestId('search-autocomplete-text-input');
+ fireEvent.changeText(textInput, 'test');
+ await flushAllUpdates();
+
+ // Simulate server results arriving: three server-only reports the client did not know about,
+ // returned by getSearchOptions in ascending reportID order (201, 202, 203).
+ getSearchOptionsSpy.mockReturnValue({
+ options: {
+ recentReports: [
+ {reportID: '201', keyForList: '201', text: 'ServerOne Report', alternateText: 'one alt', lastMessageText: 'one'},
+ {reportID: '202', keyForList: '202', text: 'ServerTwo Report', alternateText: 'two alt', lastMessageText: 'two'},
+ {reportID: '203', keyForList: '203', text: 'ServerThree Report', alternateText: 'three alt', lastMessageText: 'three'},
+ ],
+ personalDetails: [],
+ currentUserOption: null,
+ userToInvite: null,
+ },
+ });
+ mockUseFilteredOptions.mockReturnValue({
+ options: {...mockedOptions},
+ isLoading: false,
+ loadMore: jest.fn(),
+ hasMore: false,
+ isLoadingMore: false,
+ });
+
+ // Auth returned a DIFFERENT (tier) order: 203, 201, 202.
+ await act(async () => {
+ await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['203', '201', '202']);
+ await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false);
+ });
+ await flushAllUpdates();
+
+ await waitFor(() => {
+ expect(screen.getByText('Search results')).toBeTruthy();
+ });
+
+ const names = screen
+ .queryAllByText(/Report$/)
+ .map((el) => (typeof el.props.children === 'string' ? el.props.children : ''))
+ .filter((name) => ['ServerOne Report', 'ServerTwo Report', 'ServerThree Report'].includes(name));
+
+ // The server section must follow Auth's order (203, 201, 202), not the order getSearchOptions returned.
+ expect(names.indexOf('ServerThree Report')).toBeLessThan(names.indexOf('ServerOne Report'));
+ expect(names.indexOf('ServerOne Report')).toBeLessThan(names.indexOf('ServerTwo Report'));
+ });
+
+ it('should rank the selfDM first even when it is absent from the server order', async () => {
+ await waitForBatchedUpdates();
+ await Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ });
+
+ render();
+ await flushAllUpdates();
+
+ const textInput = screen.getByTestId('search-autocomplete-text-input');
+ fireEvent.changeText(textInput, 'test');
+ await flushAllUpdates();
+
+ // The selfDM plus two server reports; the server order (below) does NOT include the selfDM.
+ getSearchOptionsSpy.mockReturnValue({
+ options: {
+ recentReports: [
+ {reportID: '301', keyForList: '301', text: 'ServerA Report', alternateText: 'a', lastMessageText: 'a'},
+ {reportID: '999', keyForList: '999', text: 'MySelf Report', alternateText: 'me', lastMessageText: 'me', isSelfDM: true},
+ {reportID: '302', keyForList: '302', text: 'ServerB Report', alternateText: 'b', lastMessageText: 'b'},
+ ],
+ personalDetails: [],
+ currentUserOption: null,
+ userToInvite: null,
+ },
+ });
+ mockUseFilteredOptions.mockReturnValue({
+ options: {...mockedOptions},
+ isLoading: false,
+ loadMore: jest.fn(),
+ hasMore: false,
+ isLoadingMore: false,
+ });
+
+ // Server order lists only the two non-selfDM reports.
+ await act(async () => {
+ await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['301', '302']);
+ await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false);
+ });
+ await flushAllUpdates();
+
+ await waitFor(() => {
+ expect(screen.getByText('Search results')).toBeTruthy();
+ });
+
+ const names = screen
+ .queryAllByText(/Report$/)
+ .map((el) => (typeof el.props.children === 'string' ? el.props.children : ''))
+ .filter((name) => ['MySelf Report', 'ServerA Report', 'ServerB Report'].includes(name));
+
+ // The selfDM leads, even though it isn't in the server order.
+ expect(names.indexOf('MySelf Report')).toBe(0);
+ expect(names.indexOf('MySelf Report')).toBeLessThan(names.indexOf('ServerA Report'));
+ });
+
+ it('widens the candidate pool to the full pre-filtered set once the server returns an order', async () => {
+ await waitForBatchedUpdates();
+ await Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ });
+
+ render();
+ await flushAllUpdates();
+
+ const textInput = screen.getByTestId('search-autocomplete-text-input');
+ fireEvent.changeText(textInput, 'test');
+ await flushAllUpdates();
+
+ // Before a server order arrives, results are capped to the default suggestion limit (by recency).
+ expect(getSearchOptionsSpy).toHaveBeenLastCalledWith(expect.objectContaining({maxResults: CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS}));
+
+ await act(async () => {
+ await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['101']);
+ await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false);
+ });
+ await flushAllUpdates();
+
+ // With a server order present, the cap is lifted to the full pre-filtered pool so a low-recency top-tier report isn't culled before the Auth-order sort.
+ expect(getSearchOptionsSpy).toHaveBeenLastCalledWith(expect.objectContaining({maxResults: mockedOptions.reports.length}));
+ });
+
// Regression test for https://github.com/Expensify/App/issues/93009: after the two-section switcher was
// introduced, the first matched chat was no longer highlighted because the highlight focused a fixed flat
// index that now lands on the "Recent chats" section header row instead of the first result. As a result