Skip to content

Commit bcc436a

Browse files
ui: Value matcher perf fixes (#6210)
* Virtual list in SimpleValue Matcher component * Memoizing the leveinstein matching and debounced search when the user is typing * [pre-commit.ci lite] apply automatic fixes * Linter fix --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent fa35703 commit bcc436a

File tree

3 files changed

+3100
-11942
lines changed

3 files changed

+3100
-11942
lines changed

ui/packages/shared/profile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@storybook/preview-api": "^8.4.3",
2121
"@tanstack/react-query": "^4.0.5",
2222
"@tanstack/react-table": "^8.17.3",
23+
"@tanstack/react-virtual": "^3.5.0",
2324
"@tanstack/table-core": "^8.16.0",
2425
"@types/d3": "^7.4.3",
2526
"@types/d3-scale": "^4.0.8",

ui/packages/shared/profile/src/SimpleMatchers/Select.tsx

Lines changed: 127 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
// See the License for the specific language governing permissions and
1212
// limitations under the License.
1313

14-
import React, {Fragment, useCallback, useEffect, useRef, useState} from 'react';
14+
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
1515

1616
import {Icon} from '@iconify/react';
17+
import {useVirtualizer} from '@tanstack/react-virtual';
1718
import cx from 'classnames';
1819
import levenshtein from 'fast-levenshtein';
1920

@@ -84,11 +85,11 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
8485
const [isOpen, setIsOpen] = useState(false);
8586
const [focusedIndex, setFocusedIndex] = useState(-1);
8687
const [searchTerm, setSearchTerm] = useState('');
88+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
8789
const [isRefetching, setIsRefetching] = useState(false);
8890
const containerRef = useRef<HTMLDivElement>(null);
8991
const optionsRef = useRef<HTMLDivElement>(null);
9092
const searchInputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
91-
const optionRefs = useRef<Array<HTMLElement | null>>([]);
9293

9394
const handleRefetch = useCallback(async () => {
9495
if (refetchValues == null || isRefetching) return;
@@ -101,30 +102,34 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
101102
}
102103
}, [refetchValues, isRefetching]);
103104

104-
let items: TypedSelectItem[] = [];
105-
if (itemsProp[0] != null && 'type' in itemsProp[0]) {
106-
items = (itemsProp as GroupedSelectItem[]).flatMap(item =>
107-
item.values.map(v => ({...v, type: item.type}))
105+
useEffect(() => {
106+
const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 150);
107+
return () => clearTimeout(timer);
108+
}, [searchTerm]);
109+
110+
const items = useMemo<TypedSelectItem[]>(() => {
111+
if (itemsProp[0] != null && 'type' in itemsProp[0]) {
112+
return (itemsProp as GroupedSelectItem[]).flatMap(item =>
113+
item.values.map(v => ({...v, type: item.type}))
114+
);
115+
}
116+
return (itemsProp as SelectItem[]).map(item => ({...item, type: ''}));
117+
}, [itemsProp]);
118+
119+
const filteredItems = useMemo(() => {
120+
if (!searchable) return items;
121+
const lowerSearch = debouncedSearchTerm.toLowerCase();
122+
const filtered = items.filter(item =>
123+
item.element.active.props.children.toString().toLowerCase().includes(lowerSearch)
124+
);
125+
if (debouncedSearchTerm === '') {
126+
return filtered.sort((a, b) => a.key.localeCompare(b.key));
127+
}
128+
return filtered.sort(
129+
(a, b) =>
130+
levenshtein.get(a.key, debouncedSearchTerm) - levenshtein.get(b.key, debouncedSearchTerm)
108131
);
109-
} else {
110-
items = (itemsProp as SelectItem[]).map(item => ({...item, type: ''}));
111-
}
112-
113-
const filteredItems = searchable
114-
? items
115-
.filter(item =>
116-
item.element.active.props.children
117-
.toString()
118-
.toLowerCase()
119-
.includes(searchTerm.toLowerCase())
120-
)
121-
.sort((a, b) => {
122-
if (searchTerm === '') {
123-
return a.key.localeCompare(b.key);
124-
}
125-
return levenshtein.get(a.key, searchTerm) - levenshtein.get(b.key, searchTerm);
126-
})
127-
: items;
132+
}, [items, debouncedSearchTerm, searchable]);
128133

129134
const selection = editable ? selectedKey : items.find(v => v.key === selectedKey);
130135

@@ -147,28 +152,6 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
147152
}
148153
}, [isOpen, searchable]);
149154

150-
useEffect(() => {
151-
if (
152-
focusedIndex !== -1 &&
153-
optionsRef.current !== null &&
154-
optionRefs.current[focusedIndex] !== null
155-
) {
156-
const optionElement = optionRefs.current[focusedIndex];
157-
const optionsContainer = optionsRef.current;
158-
159-
if (optionElement !== null && optionsContainer !== null) {
160-
const optionRect = optionElement.getBoundingClientRect();
161-
const containerRect = optionsContainer.getBoundingClientRect();
162-
163-
if (optionRect.bottom > containerRect.bottom) {
164-
optionsContainer.scrollTop += optionRect.bottom - containerRect.bottom;
165-
} else if (optionRect.top < containerRect.top) {
166-
optionsContainer.scrollTop -= containerRect.top - optionRect.top;
167-
}
168-
}
169-
}
170-
}, [focusedIndex]);
171-
172155
const handleKeyDown = (e: React.KeyboardEvent): void => {
173156
if (e.key === 'Enter') {
174157
if (!isOpen) {
@@ -245,17 +228,66 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
245228
e.target.value = value;
246229
};
247230

248-
const groupedFilteredItems = filteredItems
249-
.reduce((acc: GroupedSelectItem[], item) => {
250-
const group = acc.find(g => g.type === item.type);
251-
if (group != null) {
252-
group.values.push(item);
253-
} else {
254-
acc.push({type: item.type, values: [item]});
231+
const groupedFilteredItems = useMemo(() => {
232+
return filteredItems
233+
.reduce((acc: GroupedSelectItem[], item) => {
234+
const group = acc.find(g => g.type === item.type);
235+
if (group != null) {
236+
group.values.push(item);
237+
} else {
238+
acc.push({type: item.type, values: [item]});
239+
}
240+
return acc;
241+
}, [])
242+
.sort((a, b) => a.values.length - b.values.length);
243+
}, [filteredItems]);
244+
245+
const showHeaders = useMemo(
246+
() => groupedFilteredItems.length > 1 && groupedFilteredItems.every(g => g.type !== ''),
247+
[groupedFilteredItems]
248+
);
249+
250+
const flatList = useMemo(() => {
251+
const list: Array<
252+
{type: 'header'; label: string} | {type: 'option'; item: TypedSelectItem; flatIndex: number}
253+
> = [];
254+
let optionIndex = 0;
255+
for (const group of groupedFilteredItems) {
256+
if (showHeaders && group.type !== '') {
257+
list.push({type: 'header', label: group.type});
255258
}
256-
return acc;
257-
}, [])
258-
.sort((a, b) => a.values.length - b.values.length);
259+
for (const item of group.values) {
260+
list.push({type: 'option', item: item as TypedSelectItem, flatIndex: optionIndex});
261+
optionIndex++;
262+
}
263+
}
264+
return list;
265+
}, [groupedFilteredItems, showHeaders]);
266+
267+
const longestKey = useMemo(
268+
() =>
269+
filteredItems.reduce((a, b) => (a.key.length > b.key.length ? a : b), filteredItems[0])
270+
?.key ?? '',
271+
[filteredItems]
272+
);
273+
274+
const rowVirtualizer = useVirtualizer({
275+
count: flatList.length,
276+
getScrollElement: () => optionsRef.current,
277+
estimateSize: () => 36,
278+
overscan: 500,
279+
});
280+
281+
useEffect(() => {
282+
if (focusedIndex !== -1) {
283+
const flatIdx = flatList.findIndex(
284+
entry => entry.type === 'option' && entry.flatIndex === focusedIndex
285+
);
286+
if (flatIdx !== -1) {
287+
rowVirtualizer.scrollToIndex(flatIdx, {align: 'auto'});
288+
}
289+
}
290+
}, [focusedIndex, flatList, rowVirtualizer]);
259291

260292
return (
261293
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown} onClick={onButtonClick}>
@@ -349,28 +381,45 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
349381
No values found
350382
</div>
351383
) : (
352-
groupedFilteredItems.map(group => (
353-
<Fragment key={group.type}>
354-
{groupedFilteredItems.length > 1 &&
355-
groupedFilteredItems.every(g => g.type !== '') &&
356-
group.type !== '' ? (
357-
<div className="pl-2">
358-
<DividerWithLabel label={group.type} />
384+
(() => {
385+
const virtualItems = rowVirtualizer.getVirtualItems();
386+
const paddingTop = virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
387+
const paddingBottom =
388+
virtualItems.length > 0
389+
? rowVirtualizer.getTotalSize() -
390+
(virtualItems[virtualItems.length - 1]?.end ?? 0)
391+
: 0;
392+
return (
393+
<div>
394+
<div
395+
aria-hidden
396+
className="pl-3 pr-9 whitespace-nowrap overflow-hidden"
397+
style={{height: 0, visibility: 'hidden'}}
398+
>
399+
{longestKey}
359400
</div>
360-
) : null}
361-
{group.values.map((item, index) => (
362-
<OptionItem
363-
key={item.key}
364-
item={item}
365-
index={index}
366-
optionRefs={optionRefs}
367-
focusedIndex={focusedIndex}
368-
selectedKey={selectedKey}
369-
handleSelection={handleSelection}
370-
/>
371-
))}
372-
</Fragment>
373-
))
401+
{paddingTop > 0 && <div style={{height: paddingTop}} />}
402+
{virtualItems.map(virtualItem => {
403+
const entry = flatList[virtualItem.index];
404+
return entry.type === 'header' ? (
405+
<div key={virtualItem.key} className="pl-2">
406+
<DividerWithLabel label={entry.label} />
407+
</div>
408+
) : (
409+
<OptionItem
410+
key={virtualItem.key}
411+
item={entry.item}
412+
index={entry.flatIndex}
413+
focusedIndex={focusedIndex}
414+
selectedKey={selectedKey}
415+
handleSelection={handleSelection}
416+
/>
417+
);
418+
})}
419+
{paddingBottom > 0 && <div style={{height: paddingBottom}} />}
420+
</div>
421+
);
422+
})()
374423
)}
375424
</div>
376425
{refetchValues !== undefined && loading !== true && (
@@ -392,26 +441,19 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
392441

393442
const OptionItem = ({
394443
item,
395-
optionRefs,
396444
index,
397445
focusedIndex,
398446
selectedKey,
399447
handleSelection,
400448
}: {
401449
item: SelectItem;
402-
optionRefs: React.MutableRefObject<Array<HTMLElement | null>>;
403450
index: number;
404451
focusedIndex: number;
405452
selectedKey: string | undefined;
406453
handleSelection: (value: string) => void;
407454
}): JSX.Element => {
408455
return (
409456
<div
410-
ref={el => {
411-
if (el !== null) {
412-
optionRefs.current[index] = el;
413-
}
414-
}}
415457
className={cx(
416458
'relative cursor-default select-none py-2 pl-3 pr-9',
417459
index === focusedIndex && 'bg-indigo-600 text-white',

0 commit comments

Comments
 (0)