Skip to content
Draft
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
25 changes: 16 additions & 9 deletions src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1572,9 +1572,9 @@ const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArc
*
* Performance optimization approach:
* 1. Pre-filters reports using shouldReportBeInOptionList with correct parameters (betas, etc.)
* 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.
* 2. Default (`options.isSearching` false): pre-computes each report's sort key once, then uses a heap to
* select the top N reports (`maxRecentReports`) by self-DM status, archived status, and recency. Only
* those reports are processed in step 4, avoiding work on 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.
*
Expand Down Expand Up @@ -1722,7 +1722,7 @@ function createFilteredOptionList(
return !!report;
});

// Step 2: Sort by lastVisibleActionCreated (most recent first) and limit to top N
// Step 2: Select the top N reports by priority (self-DM, then non-archived, then most recent).
// In search mode, skip sorting because we return all reports anyway - sorting is unnecessary
const sortedReports = isSearching ? reportsArray : optionsOrderBy(reportsArray, (report) => reportSortComparator(report, privateIsArchivedMap), maxRecentReports).options;

Expand Down Expand Up @@ -1915,33 +1915,40 @@ function optionsOrderBy<T = SearchOptionData | PersonalDetailOptionData>(
filter?: (option: T) => boolean | undefined,
reversed = false,
): {options: T[]; hasMore: boolean} {
const heap = reversed ? new MaxHeap<T>(comparator) : new MinHeap<T>(comparator);
let hasMore = false;

// If a limit is 0 or negative, return an empty array
if (limit !== undefined && limit <= 0) {
return {options: [], hasMore};
}

// Decorate each option with its comparator key once.
// The heap then compares precomputed primitives instead of re-running `comparator`
// on every O(log n) heap comparison, so `comparator` is evaluated exactly once per option.
type Decorated = {key: number | string; option: T};
const getKey = (decorated: Decorated) => decorated.key;
const heap = reversed ? new MaxHeap<Decorated>(getKey) : new MinHeap<Decorated>(getKey);

for (const option of options) {
if (filter && !filter(option)) {
continue;
}
const decorated: Decorated = {key: comparator(option), option};
if (limit !== undefined && heap.size() >= limit) {
hasMore = true;
const peekedValue = heap.peek();
if (!peekedValue) {
throw new Error('Heap is empty, cannot peek value');
}
if (reversed ? comparator(option) < comparator(peekedValue) : comparator(option) > comparator(peekedValue)) {
if (reversed ? decorated.key < peekedValue.key : decorated.key > peekedValue.key) {
heap.pop();
heap.push(option);
heap.push(decorated);
}
} else {
heap.push(option);
heap.push(decorated);
}
}
return {options: [...heap].reverse(), hasMore};
return {options: [...heap].reverse().map((decorated) => decorated.option), hasMore};
}

/**
Expand Down
77 changes: 70 additions & 7 deletions tests/unit/OptionsListUtilsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8442,6 +8442,66 @@ describe('OptionsListUtils', () => {
});

describe('createFilteredOptionList', () => {
const createChatReport = (reportID: string, lastVisibleActionCreated: string, chatType?: Report['chatType']): Report => ({
reportID,
reportName: `Report ${reportID}`,
type: CONST.REPORT.TYPE.CHAT,
chatType,
lastVisibleActionCreated,
participants: {
[CURRENT_USER_ACCOUNT_ID]: {
notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
},
1: {
notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
},
},
});

const reportsToCollection = (reports: Report[]): OnyxCollection<Report> => Object.fromEntries(reports.map((report) => [report.reportID, report]));

it('returns the most recent reports when maxRecentReports is less than the input size', () => {
const reports = [createChatReport('101', '2022-01-01 00:00:00'), createChatReport('102', '2024-01-01 00:00:00'), createChatReport('103', '2023-01-01 00:00:00')];

const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, {maxRecentReports: 2, includeP2P: false});

expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102', '103']);
});

it('prioritizes self-DM over newer non-self-DM reports', () => {
const reports = [createChatReport('101', '2024-01-01 00:00:00'), createChatReport('102', '2020-01-01 00:00:00', CONST.REPORT.CHAT_TYPE.SELF_DM)];

const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, {maxRecentReports: 1, includeP2P: false});

expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102']);
});

it('prioritizes non-archived reports over newer archived reports', () => {
const reports = [createChatReport('101', '2022-01-01 00:00:00'), createChatReport('102', '2024-01-01 00:00:00')];
const privateIsArchivedMap: PrivateIsArchivedMap = {
[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}102`]: true,
};

const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, privateIsArchivedMap, undefined, {maxRecentReports: 1, includeP2P: false});

expect(result.reports.map((option) => option.item?.reportID)).toEqual(['101']);
});

it('returns all reports when isSearching is true', () => {
const reports = [createChatReport('101', '2022-01-01 00:00:00'), createChatReport('102', '2024-01-01 00:00:00'), createChatReport('103', '2023-01-01 00:00:00')];

const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, {maxRecentReports: 1, isSearching: true, includeP2P: false});

expect(result.reports.map((option) => option.item?.reportID)).toEqual(['101', '102', '103']);
});

it('returns an empty array when maxRecentReports is zero', () => {
const reports = [createChatReport('101', '2024-01-01 00:00:00')];

const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, {maxRecentReports: 0, includeP2P: false});

expect(result.reports).toEqual([]);
});
it('should return report options limited by maxRecentReports', () => {
const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, {maxRecentReports: 5});

Expand All @@ -8450,8 +8510,8 @@ describe('OptionsListUtils', () => {
});

it('should sort reports by lastVisibleActionCreated (most recent first)', () => {
const reportsWithDates: OnyxCollection<Report> = {
'101': {
const reportsWithDates = [
{
reportID: '101',
reportName: 'Oldest Report',
type: CONST.REPORT.TYPE.CHAT,
Expand All @@ -8465,7 +8525,7 @@ describe('OptionsListUtils', () => {
},
},
},
'102': {
{
reportID: '102',
reportName: 'Newest Report',
type: CONST.REPORT.TYPE.CHAT,
Expand All @@ -8479,7 +8539,7 @@ describe('OptionsListUtils', () => {
},
},
},
'103': {
{
reportID: '103',
reportName: 'Middle Report',
type: CONST.REPORT.TYPE.CHAT,
Expand All @@ -8493,11 +8553,14 @@ describe('OptionsListUtils', () => {
},
},
},
};
] satisfies Report[];

const result = createFilteredOptionList(PERSONAL_DETAILS, reportsWithDates, undefined, {}, undefined, {maxRecentReports: 3});
const result = createFilteredOptionList({}, Object.fromEntries(reportsWithDates.map((report) => [report.reportID, report])), undefined, {}, undefined, {
maxRecentReports: 3,
includeP2P: false,
});

expect(result.reports.length).toBeGreaterThan(0);
expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102', '103', '101']);
});

it('should include personal details when includeP2P is true', () => {
Expand Down
Loading