fix: stop re-sorting the contact list on every search keystroke

Typing in the Contacts search re-ran the full sort on each keystroke,
with `localeCompare` computing both display names and setting up a
collator for every comparison. The list is now sorted once per
contacts, category or sort-order change and search only filters the
sorted list; the sort itself computes each sort name once and reuses
a single `Intl.Collator` (PF10).
This commit is contained in:
Linus Rath
2026-09-24 19:03:34 +02:00
parent 182774884e
commit e6be8922b7
3 changed files with 32 additions and 10 deletions
+18 -7
View File
@@ -50,8 +50,8 @@ interface Section {
data: ContactCard[]; data: ContactCard[];
} }
function groupContacts(contacts: ContactCard[], byLastName: boolean): Section[] { /** Letter sections for contacts already in `sortContactsByName` order. */
const sorted = sortContactsByName(contacts, byLastName); function groupContacts(sorted: ContactCard[], byLastName: boolean): Section[] {
const groups: Record<string, ContactCard[]> = {}; const groups: Record<string, ContactCard[]> = {};
for (const c of sorted) { for (const c of sorted) {
// Sections follow the sort name, so with "sort by last name" Alice Smith // Sections follow the sort name, so with "sort by last name" Alice Smith
@@ -137,7 +137,7 @@ export default function ContactsScreen() {
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]); const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]); const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const visible = React.useMemo(() => { const categoryContacts = React.useMemo(() => {
let filtered: ContactCard[]; let filtered: ContactCard[];
switch (selectedCategory.type) { switch (selectedCategory.type) {
case 'all': case 'all':
@@ -162,14 +162,25 @@ export default function ContactsScreen() {
default: default:
filtered = individuals; filtered = individuals;
} }
if (!searchQuery) return filtered; return filtered;
return filtered.filter((c) => matchesContactSearch(c, searchQuery)); }, [contacts, individuals, selectedCategory]);
}, [contacts, individuals, selectedCategory, searchQuery]);
// Sort once per list or order change; search only filters the sorted list,
// so typing never re-sorts the address book (PF10).
const sorted = React.useMemo(
() => sortContactsByName(categoryContacts, sortByLastName),
[categoryContacts, sortByLastName],
);
const visible = React.useMemo(() => {
if (!searchQuery) return sorted;
return sorted.filter((c) => matchesContactSearch(c, searchQuery));
}, [sorted, searchQuery]);
const sections = React.useMemo<Section[]>(() => { const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible, sortByLastName); if (groupByLetter) return groupContacts(visible, sortByLastName);
// Flat list - single unnamed section keeps SectionList rendering simple. // Flat list - single unnamed section keeps SectionList rendering simple.
return [{ title: '', data: sortContactsByName(visible, sortByLastName) }]; return [{ title: '', data: visible }];
}, [visible, groupByLetter, sortByLastName]); }, [visible, groupByLetter, sortByLastName]);
const selectionMode = selection.size > 0; const selectionMode = selection.size > 0;
@@ -526,4 +526,10 @@ describe('sortContactsByName (#963)', () => {
sortContactsByName(input, true); sortContactsByName(input, true);
expect(input.map((c) => c.id)).toEqual(['zoe', 'alice', 'bob', 'acme']); expect(input.map((c) => c.id)).toEqual(['zoe', 'alice', 'bob', 'acme']);
}); });
it('compares case- and accent-insensitively, keeping ties in list order (PF10)', () => {
const named = (id: string, full: string) => card(id, { name: { full } });
const list = [named('z', 'Zed'), named('e2', 'emile'), named('e1', 'Émile'), named('a', 'Anna')];
expect(sortContactsByName(list, false).map((c) => c.id)).toEqual(['a', 'e2', 'e1', 'z']);
});
}); });
+8 -3
View File
@@ -928,9 +928,14 @@ export function sortContactsByDisplayName(contacts: ContactCard[]): ContactCard[
return sortContactsByName(contacts, false); return sortContactsByName(contacts, false);
} }
// One collator for every contact sort: `localeCompare` with options sets one
// up per comparison, which made a large address book slow to sort (PF10).
const contactNameCollator = new Intl.Collator(undefined, { sensitivity: 'base' });
/** Contact list order: by display name, or surname first with `byLastName` (#963). */ /** Contact list order: by display name, or surname first with `byLastName` (#963). */
export function sortContactsByName(contacts: ContactCard[], byLastName: boolean): ContactCard[] { export function sortContactsByName(contacts: ContactCard[], byLastName: boolean): ContactCard[] {
return [...contacts].sort((a, b) => // Work out each sort name once instead of twice per comparison.
getContactSortName(a, byLastName).localeCompare(getContactSortName(b, byLastName), undefined, { sensitivity: 'base' }), const keyed = contacts.map((contact) => ({ contact, key: getContactSortName(contact, byLastName) }));
); keyed.sort((a, b) => contactNameCollator.compare(a.key, b.key));
return keyed.map((entry) => entry.contact);
} }