Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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;
Expand Down
34 changes: 31 additions & 3 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -244,6 +246,7 @@ function SearchAutocompleteList({
sortedActions,
conciergeReportID,
isTrackIntentUser,
searchResultReportIDs,
]);

const [isInitialRender, setIsInitialRender] = useState(true);
Expand Down Expand Up @@ -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) {
Comment thread
carlosmiceli marked this conversation as resolved.
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));
Comment thread
carlosmiceli marked this conversation as resolved.
}

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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -568,6 +595,7 @@ function SearchAutocompleteList({
recentSearchesData,
searchOptions,
searchQueryItems,
searchResultReportIDs,
styles,
translate,
isLoadingOptions,
Expand Down
4 changes: 4 additions & 0 deletions src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions tests/actions/ReportTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
144 changes: 144 additions & 0 deletions tests/unit/SearchAutocompleteListTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SearchRouterWrapper />);
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(<SearchRouterWrapper />);
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(<SearchRouterWrapper />);
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
Expand Down
Loading