Skip to content

Commit 8737238

Browse files
authored
Merge pull request #87847 from callstack-internal/perf/optimize-lhn-sorting
Optimize LHN sidebar sorting with pre-computed sort keys
2 parents d2c8c31 + 753cc05 commit 8737238

2 files changed

Lines changed: 114 additions & 44 deletions

File tree

src/libs/SidebarUtils.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,40 @@ function compareStringDates(a: string, b: string): 0 | 1 | -1 {
223223
return 0;
224224
}
225225

226+
const NUMERIC_PAD_WIDTH = 15;
227+
const DIGIT_SEQUENCE = /\d+/g;
228+
229+
/**
230+
* Persists across renders so sort keys are computed at most once per unique display name.
231+
*/
232+
const sortKeyCache = new Map<string, string>();
233+
234+
/**
235+
* Builds a normalized sort key for fast string comparison using plain < / > operators.
236+
* Lowercases the name and zero-pads numeric segments ("Report 2" → "report 000000000000002")
237+
* so that numeric ordering is preserved without Intl.Collator.
238+
*
239+
* Results are cached at module level so each unique name pays the cost only once.
240+
*/
241+
function buildSortKey(displayName: string): string {
242+
const cached = sortKeyCache.get(displayName);
243+
if (cached !== undefined) {
244+
return cached;
245+
}
246+
247+
const key = displayName.toLowerCase().replaceAll(DIGIT_SEQUENCE, (match) => match.padStart(NUMERIC_PAD_WIDTH, '0'));
248+
sortKeyCache.set(displayName, key);
249+
return key;
250+
}
251+
226252
/**
227253
* A mini report object that contains only the necessary information to sort reports.
228254
* This is used to avoid copying the entire report object and only the necessary information.
229255
*/
230256
type MiniReport = {
231257
reportID?: string;
232258
displayName: string;
259+
sortKey: string;
233260
lastVisibleActionCreated?: string;
234261
};
235262

@@ -445,6 +472,8 @@ function categorizeReportsForLHN(
445472
reportAttributes: ReportAttributesDerivedValue['reports'] | undefined,
446473
reportNameValuePairs?: OnyxCollection<ReportNameValuePairs>,
447474
) {
475+
sortKeyCache.clear();
476+
448477
const pinnedAndGBRReports: MiniReport[] = [];
449478
const errorReports: MiniReport[] = [];
450479
const draftReports: MiniReport[] = [];
@@ -461,6 +490,7 @@ function categorizeReportsForLHN(
461490
const miniReport: MiniReport = {
462491
reportID,
463492
displayName,
493+
sortKey: buildSortKey(displayName),
464494
lastVisibleActionCreated: report.lastVisibleActionCreated,
465495
};
466496

@@ -520,8 +550,19 @@ function sortCategorizedReports(
520550
} {
521551
const {pinnedAndGBRReports, errorReports, draftReports, nonArchivedReports, archivedReports} = categories;
522552

523-
// Create comparison functions once to avoid recreating them in sort
524-
const compareDisplayNames = (a: MiniReport, b: MiniReport) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0);
553+
const compareDisplayNames = (a: MiniReport, b: MiniReport) => {
554+
if (a.sortKey < b.sortKey) {
555+
return -1;
556+
}
557+
if (a.sortKey > b.sortKey) {
558+
return 1;
559+
}
560+
if (!a.displayName || !b.displayName) {
561+
return 0;
562+
}
563+
// Sort keys tied — fall back to Collator for locale-correct ordering
564+
return localeCompare(a.displayName, b.displayName);
565+
};
525566

526567
const compareDatesDesc = (a: MiniReport, b: MiniReport) =>
527568
a?.lastVisibleActionCreated && b?.lastVisibleActionCreated ? compareStringDates(b.lastVisibleActionCreated, a.lastVisibleActionCreated) : 0;
@@ -1376,7 +1417,12 @@ function getRoomWelcomeMessage(
13761417
}
13771418

13781419
// Exported for unit testing only. Do not use directly in production code.
1379-
export {categorizeReportsForLHN as _categorizeReportsForLHN, sortCategorizedReports as _sortCategorizedReports, combineReportCategories as _combineReportCategories};
1420+
export {
1421+
categorizeReportsForLHN as _categorizeReportsForLHN,
1422+
sortCategorizedReports as _sortCategorizedReports,
1423+
combineReportCategories as _combineReportCategories,
1424+
buildSortKey as _buildSortKey,
1425+
};
13801426

13811427
export default {
13821428
getOptionData,

tests/unit/SidebarUtilsTest.ts

Lines changed: 65 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {getLastActorDisplayName} from '@libs/OptionsListUtils';
1010
import type * as PolicyUtils from '@libs/PolicyUtils';
1111
import {getOriginalMessage, getReportActionMessageText} from '@libs/ReportActionsUtils';
1212
import {formatReportLastMessageText, generateReportID, getAllReportErrors, getReasonAndReportActionThatRequiresAttention, getReportPreviewMessage} from '@libs/ReportUtils';
13-
import SidebarUtils, {_categorizeReportsForLHN, _combineReportCategories, _sortCategorizedReports} from '@libs/SidebarUtils';
13+
import SidebarUtils, {_buildSortKey, _categorizeReportsForLHN, _combineReportCategories, _sortCategorizedReports} from '@libs/SidebarUtils';
1414
import initOnyxDerivedValues from '@userActions/OnyxDerived';
1515
import CONST from '@src/CONST';
1616
import IntlStore from '@src/languages/IntlStore';
@@ -3231,24 +3231,24 @@ describe('SidebarUtils', () => {
32313231
// Given the reports are created
32323232
const categories = {
32333233
pinnedAndGBRReports: [
3234-
{reportID: '1', displayName: 'Zebra', lastVisibleActionCreated: '2024-01-01 10:00:00'},
3235-
{reportID: '2', displayName: 'Alpha', lastVisibleActionCreated: '2024-01-02 10:00:00'},
3234+
{reportID: '1', displayName: 'Zebra', sortKey: 'zebra', lastVisibleActionCreated: '2024-01-01 10:00:00'},
3235+
{reportID: '2', displayName: 'Alpha', sortKey: 'alpha', lastVisibleActionCreated: '2024-01-02 10:00:00'},
32363236
],
32373237
errorReports: [
3238-
{reportID: '3', displayName: 'Charlie', lastVisibleActionCreated: '2024-01-03 10:00:00'},
3239-
{reportID: '4', displayName: 'Beta', lastVisibleActionCreated: '2024-01-04 10:00:00'},
3238+
{reportID: '3', displayName: 'Charlie', sortKey: 'charlie', lastVisibleActionCreated: '2024-01-03 10:00:00'},
3239+
{reportID: '4', displayName: 'Beta', sortKey: 'beta', lastVisibleActionCreated: '2024-01-04 10:00:00'},
32403240
],
32413241
draftReports: [
3242-
{reportID: '5', displayName: 'Echo', lastVisibleActionCreated: '2024-01-05 10:00:00'},
3243-
{reportID: '6', displayName: 'Delta', lastVisibleActionCreated: '2024-01-06 10:00:00'},
3242+
{reportID: '5', displayName: 'Echo', sortKey: 'echo', lastVisibleActionCreated: '2024-01-05 10:00:00'},
3243+
{reportID: '6', displayName: 'Delta', sortKey: 'delta', lastVisibleActionCreated: '2024-01-06 10:00:00'},
32443244
],
32453245
nonArchivedReports: [
3246-
{reportID: '7', displayName: 'Hotel', lastVisibleActionCreated: '2024-01-07 10:00:00'},
3247-
{reportID: '8', displayName: 'Golf', lastVisibleActionCreated: '2024-01-08 10:00:00'},
3246+
{reportID: '7', displayName: 'Hotel', sortKey: 'hotel', lastVisibleActionCreated: '2024-01-07 10:00:00'},
3247+
{reportID: '8', displayName: 'Golf', sortKey: 'golf', lastVisibleActionCreated: '2024-01-08 10:00:00'},
32483248
],
32493249
archivedReports: [
3250-
{reportID: '9', displayName: 'India', lastVisibleActionCreated: '2024-01-09 10:00:00'},
3251-
{reportID: '10', displayName: 'Juliet', lastVisibleActionCreated: '2024-01-10 10:00:00'},
3250+
{reportID: '9', displayName: 'India', sortKey: 'india', lastVisibleActionCreated: '2024-01-09 10:00:00'},
3251+
{reportID: '10', displayName: 'Juliet', sortKey: 'juliet', lastVisibleActionCreated: '2024-01-10 10:00:00'},
32523252
],
32533253
};
32543254

@@ -3280,24 +3280,24 @@ describe('SidebarUtils', () => {
32803280
// Given the reports are created
32813281
const categories = {
32823282
pinnedAndGBRReports: [
3283-
{reportID: '1', displayName: 'Zebra', lastVisibleActionCreated: '2024-01-01 10:00:00'},
3284-
{reportID: '2', displayName: 'Alpha', lastVisibleActionCreated: '2024-01-02 10:00:00'},
3283+
{reportID: '1', displayName: 'Zebra', sortKey: 'zebra', lastVisibleActionCreated: '2024-01-01 10:00:00'},
3284+
{reportID: '2', displayName: 'Alpha', sortKey: 'alpha', lastVisibleActionCreated: '2024-01-02 10:00:00'},
32853285
],
32863286
errorReports: [
3287-
{reportID: '3', displayName: 'Charlie', lastVisibleActionCreated: '2024-01-03 10:00:00'},
3288-
{reportID: '4', displayName: 'Beta', lastVisibleActionCreated: '2024-01-04 10:00:00'},
3287+
{reportID: '3', displayName: 'Charlie', sortKey: 'charlie', lastVisibleActionCreated: '2024-01-03 10:00:00'},
3288+
{reportID: '4', displayName: 'Beta', sortKey: 'beta', lastVisibleActionCreated: '2024-01-04 10:00:00'},
32893289
],
32903290
draftReports: [
3291-
{reportID: '5', displayName: 'Echo', lastVisibleActionCreated: '2024-01-05 10:00:00'},
3292-
{reportID: '6', displayName: 'Delta', lastVisibleActionCreated: '2024-01-06 10:00:00'},
3291+
{reportID: '5', displayName: 'Echo', sortKey: 'echo', lastVisibleActionCreated: '2024-01-05 10:00:00'},
3292+
{reportID: '6', displayName: 'Delta', sortKey: 'delta', lastVisibleActionCreated: '2024-01-06 10:00:00'},
32933293
],
32943294
nonArchivedReports: [
3295-
{reportID: '7', displayName: 'Hotel', lastVisibleActionCreated: '2024-01-07 10:00:00'},
3296-
{reportID: '8', displayName: 'Golf', lastVisibleActionCreated: '2024-01-08 10:00:00'},
3295+
{reportID: '7', displayName: 'Hotel', sortKey: 'hotel', lastVisibleActionCreated: '2024-01-07 10:00:00'},
3296+
{reportID: '8', displayName: 'Golf', sortKey: 'golf', lastVisibleActionCreated: '2024-01-08 10:00:00'},
32973297
],
32983298
archivedReports: [
3299-
{reportID: '9', displayName: 'India', lastVisibleActionCreated: '2024-01-09 10:00:00'},
3300-
{reportID: '10', displayName: 'Juliet', lastVisibleActionCreated: '2024-01-10 10:00:00'},
3299+
{reportID: '9', displayName: 'India', sortKey: 'india', lastVisibleActionCreated: '2024-01-09 10:00:00'},
3300+
{reportID: '10', displayName: 'Juliet', sortKey: 'juliet', lastVisibleActionCreated: '2024-01-10 10:00:00'},
33013301
],
33023302
};
33033303

@@ -3329,8 +3329,8 @@ describe('SidebarUtils', () => {
33293329
// Given the reports are created
33303330
const categories = {
33313331
pinnedAndGBRReports: [
3332-
{reportID: '1', displayName: '', lastVisibleActionCreated: '2024-01-01 10:00:00'},
3333-
{reportID: '2', displayName: 'Alpha', lastVisibleActionCreated: '2024-01-02 10:00:00'},
3332+
{reportID: '1', displayName: '', sortKey: '', lastVisibleActionCreated: '2024-01-01 10:00:00'},
3333+
{reportID: '2', displayName: 'Alpha', sortKey: 'alpha', lastVisibleActionCreated: '2024-01-02 10:00:00'},
33343334
],
33353335
errorReports: [],
33363336
draftReports: [],
@@ -3352,8 +3352,8 @@ describe('SidebarUtils', () => {
33523352
errorReports: [],
33533353
draftReports: [],
33543354
nonArchivedReports: [
3355-
{reportID: '1', displayName: 'Alpha', lastVisibleActionCreated: undefined},
3356-
{reportID: '2', displayName: 'Beta', lastVisibleActionCreated: '2024-01-02 10:00:00'},
3355+
{reportID: '1', displayName: 'Alpha', sortKey: 'alpha', lastVisibleActionCreated: undefined},
3356+
{reportID: '2', displayName: 'Beta', sortKey: 'beta', lastVisibleActionCreated: '2024-01-02 10:00:00'},
33573357
],
33583358
archivedReports: [],
33593359
};
@@ -3367,28 +3367,52 @@ describe('SidebarUtils', () => {
33673367
});
33683368
});
33693369

3370+
describe('buildSortKey', () => {
3371+
it('should sort accented characters by Unicode code point, not locale-aware order', () => {
3372+
// Given names with accented characters
3373+
const cafeAccented = _buildSortKey('Café');
3374+
const cafePlain = _buildSortKey('Cafe');
3375+
3376+
// Then accented "é" sorts after plain "e" by code point
3377+
expect(cafeAccented > cafePlain).toBe(true);
3378+
});
3379+
3380+
it('should be case-insensitive', () => {
3381+
expect(_buildSortKey('Alpha')).toBe(_buildSortKey('alpha'));
3382+
expect(_buildSortKey('ZEBRA')).toBe(_buildSortKey('zebra'));
3383+
});
3384+
3385+
it('should zero-pad numeric segments for natural sort order', () => {
3386+
const report2 = _buildSortKey('Report 2');
3387+
const report10 = _buildSortKey('Report 10');
3388+
3389+
// Then "Report 2" sorts before "Report 10"
3390+
expect(report2 < report10).toBe(true);
3391+
});
3392+
});
3393+
33703394
describe('combineReportCategories', () => {
33713395
it('should combine categories in correct order', () => {
33723396
// Given the reports are created
33733397
const pinnedAndGBRReports = [
3374-
{reportID: '1', displayName: 'Pinned 1'},
3375-
{reportID: '2', displayName: 'Pinned 2'},
3398+
{reportID: '1', displayName: 'Pinned 1', sortKey: 'pinned 000000000000001'},
3399+
{reportID: '2', displayName: 'Pinned 2', sortKey: 'pinned 000000000000002'},
33763400
];
33773401
const errorReports = [
3378-
{reportID: '3', displayName: 'Error 1'},
3379-
{reportID: '4', displayName: 'Error 2'},
3402+
{reportID: '3', displayName: 'Error 1', sortKey: 'error 000000000000001'},
3403+
{reportID: '4', displayName: 'Error 2', sortKey: 'error 000000000000002'},
33803404
];
33813405
const draftReports = [
3382-
{reportID: '5', displayName: 'Draft 1'},
3383-
{reportID: '6', displayName: 'Draft 2'},
3406+
{reportID: '5', displayName: 'Draft 1', sortKey: 'draft 000000000000001'},
3407+
{reportID: '6', displayName: 'Draft 2', sortKey: 'draft 000000000000002'},
33843408
];
33853409
const nonArchivedReports = [
3386-
{reportID: '7', displayName: 'Normal 1'},
3387-
{reportID: '8', displayName: 'Normal 2'},
3410+
{reportID: '7', displayName: 'Normal 1', sortKey: 'normal 000000000000001'},
3411+
{reportID: '8', displayName: 'Normal 2', sortKey: 'normal 000000000000002'},
33883412
];
33893413
const archivedReports = [
3390-
{reportID: '9', displayName: 'Archived 1'},
3391-
{reportID: '10', displayName: 'Archived 2'},
3414+
{reportID: '9', displayName: 'Archived 1', sortKey: 'archived 000000000000001'},
3415+
{reportID: '10', displayName: 'Archived 2', sortKey: 'archived 000000000000002'},
33923416
];
33933417

33943418
// When the reports are combined
@@ -3401,13 +3425,13 @@ describe('SidebarUtils', () => {
34013425
it('should filter out reports with undefined reportID', () => {
34023426
// Given the reports are created
34033427
const pinnedAndGBRReports = [
3404-
{reportID: '1', displayName: 'Pinned 1'},
3405-
{reportID: undefined, displayName: 'Invalid'},
3428+
{reportID: '1', displayName: 'Pinned 1', sortKey: 'pinned 000000000000001'},
3429+
{reportID: undefined, displayName: 'Invalid', sortKey: 'invalid'},
34063430
];
3407-
const errorReports = [{reportID: '2', displayName: 'Error 1'}];
3408-
const draftReports: Array<{reportID?: string; displayName: string; lastVisibleActionCreated?: string}> = [];
3409-
const nonArchivedReports: Array<{reportID?: string; displayName: string; lastVisibleActionCreated?: string}> = [];
3410-
const archivedReports: Array<{reportID?: string; displayName: string; lastVisibleActionCreated?: string}> = [];
3431+
const errorReports = [{reportID: '2', displayName: 'Error 1', sortKey: 'error 000000000000001'}];
3432+
const draftReports: Array<{reportID?: string; displayName: string; sortKey: string; lastVisibleActionCreated?: string}> = [];
3433+
const nonArchivedReports: Array<{reportID?: string; displayName: string; sortKey: string; lastVisibleActionCreated?: string}> = [];
3434+
const archivedReports: Array<{reportID?: string; displayName: string; sortKey: string; lastVisibleActionCreated?: string}> = [];
34113435

34123436
// When the reports are combined
34133437
const result = _combineReportCategories(pinnedAndGBRReports, errorReports, draftReports, nonArchivedReports, archivedReports);

0 commit comments

Comments
 (0)