Combobox: Refactor sortByGroup for performance (#102664)

* first pass at improving perf of sortByGroup

* polish up
This commit is contained in:
Josh Hunt
2025-03-24 15:57:00 +00:00
committed by GitHub
parent 1adcee17ca
commit 41cc82b8d0
2 changed files with 94 additions and 21 deletions
@@ -1,6 +1,6 @@
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act, waitFor } from '@testing-library/react';
import { useOptions } from './useOptions'; import { sortByGroup, useOptions } from './useOptions';
describe('useOptions', () => { describe('useOptions', () => {
it('should handle a large number of synchronous options without throwing an error', () => { it('should handle a large number of synchronous options without throwing an error', () => {
@@ -101,3 +101,56 @@ describe('useOptions', () => {
expect(result.current.asyncError).toBe(true); expect(result.current.asyncError).toBe(true);
}); });
}); });
describe('sortByGroup', () => {
it('should return original array when no groups exist', () => {
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Carrot', value: 'carrot' },
];
const { options: sortedOptions, groupStartIndices } = sortByGroup(options);
expect(sortedOptions).toBe(options); // Check reference equality
expect(groupStartIndices.size).toBe(0);
});
it('should return original array when only one group exists', () => {
const options = [
{ label: 'Apple', value: 'apple', group: 'fruits' },
{ label: 'Banana', value: 'banana', group: 'fruits' },
{ label: 'Tomato', value: 'tomato', group: 'fruits' },
];
const { options: sortedOptions, groupStartIndices } = sortByGroup(options);
expect(sortedOptions).toEqual(options);
expect(groupStartIndices.size).toBe(1);
expect(groupStartIndices.get('fruits')).toBe(0);
});
it('should group options and track group start indices', () => {
const options = [
{ label: 'Apple', value: 'apple', group: 'fruits' },
{ label: 'Carrot', value: 'carrot', group: 'vegetables' },
{ label: 'Banana', value: 'banana', group: 'fruits' },
{ label: 'Celery', value: 'celery', group: 'vegetables' },
{ label: 'Other', value: 'other' }, // Ungrouped
];
const { options: sortedOptions, groupStartIndices } = sortByGroup(options);
expect(sortedOptions).toEqual([
{ label: 'Apple', value: 'apple', group: 'fruits' },
{ label: 'Banana', value: 'banana', group: 'fruits' },
{ label: 'Carrot', value: 'carrot', group: 'vegetables' },
{ label: 'Celery', value: 'celery', group: 'vegetables' },
{ label: 'Other', value: 'other' },
]);
expect(groupStartIndices.size).toBe(2);
expect(groupStartIndices.get('fruits')).toBe(0);
expect(groupStartIndices.get('vegetables')).toBe(2);
});
});
@@ -115,41 +115,61 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
return { options: finalOptions, groupStartIndices, updateOptions, asyncLoading, asyncError }; return { options: finalOptions, groupStartIndices, updateOptions, asyncLoading, asyncError };
} }
function sortByGroup<T extends string | number>(options: Array<ComboboxOption<T>>) { /**
* Sorts options by group and returns the sorted options and the starting index of each group
*/
export function sortByGroup<T extends string | number>(options: Array<ComboboxOption<T>>) {
// Group options by their group
const groupedOptions = new Map<string | undefined, Array<ComboboxOption<T>>>(); const groupedOptions = new Map<string | undefined, Array<ComboboxOption<T>>>();
const groupStartIndices = new Map<string | undefined, number>();
for (const option of options) { for (const option of options) {
const groupExists = groupedOptions.has(option.group); const group = option.group;
if (groupExists) { const existing = groupedOptions.get(group);
groupedOptions.get(option.group)?.push(option); if (existing) {
existing.push(option);
} else { } else {
groupedOptions.set(option.group, [option]); groupedOptions.set(group, [option]);
} }
} }
// Create a map to track the starting index of each group // If we only have one group (either the undefined group, or a single group), return the original array
const groupStartIndices = new Map<string, number>(); if (groupedOptions.size <= 1) {
if (options[0]?.group) {
groupStartIndices.set(options[0]?.group, 0);
}
return {
options,
groupStartIndices,
};
}
// 'Preallocate' result array with same size as input - very minor optimization
const result: Array<ComboboxOption<T>> = new Array(options.length);
let currentIndex = 0; let currentIndex = 0;
// Reorganize options to have groups first, then undefined group // Fill result array with grouped options
let reorganizeOptions: Array<ComboboxOption<T>> = [];
for (const [group, groupOptions] of groupedOptions) { for (const [group, groupOptions] of groupedOptions) {
if (!group) { if (group) {
continue; groupStartIndices.set(group, currentIndex);
for (const option of groupOptions) {
result[currentIndex++] = option;
}
} }
groupStartIndices.set(group, currentIndex);
reorganizeOptions = reorganizeOptions.concat(groupOptions);
currentIndex += groupOptions.length;
} }
const undefinedGroupOptions = groupedOptions.get(undefined); // Add ungrouped options at the end
if (undefinedGroupOptions) { const ungrouped = groupedOptions.get(undefined);
groupStartIndices.set('undefined', currentIndex); if (ungrouped) {
reorganizeOptions = reorganizeOptions.concat(undefinedGroupOptions); for (const option of ungrouped) {
result[currentIndex++] = option;
}
} }
return { return {
options: reorganizeOptions, options: result,
groupStartIndices, groupStartIndices,
}; };
} }