Skip to content

Commit e431872

Browse files
stefanonardoclaude
andcommitted
OCPBUGS-81518: Fix ResourceListDropdown performance on large clusters
The Search page resource type dropdown was unusable on clusters with 1000+ API resources due to O(n²) filtering, missing memoization, redundant computations on every render, and rendering all items to the DOM without any limit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 46a9c16 commit e431872

2 files changed

Lines changed: 129 additions & 92 deletions

File tree

frontend/public/components/resource-dropdown.tsx

Lines changed: 128 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FC, FormEvent, KeyboardEvent, Ref } from 'react';
2-
import { useState, useRef, useEffect, Fragment } from 'react';
2+
import { useState, useRef, useEffect, useMemo, Fragment } from 'react';
33
import * as _ from 'lodash';
44
import { connect } from 'react-redux';
55
import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
@@ -28,6 +28,7 @@ import {
2828
import { TimesIcon } from '@patternfly/react-icons';
2929

3030
const RECENT_SEARCH_ITEMS = 5;
31+
const MAX_VISIBLE_ITEMS = 100;
3132

3233
// Blacklist known duplicate resources.
3334
const blacklistGroups = ImmutableSet([
@@ -44,59 +45,74 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
4445
const { selected, onChange, recentList, allModels, groupToVersionMap, className } = props;
4546
const { t } = useTranslation();
4647
const [isOpen, setIsOpen] = useState(false);
47-
const [clearItems, setClearItems] = useState(false);
4848
const [recentSelected, setRecentSelected] = useUserPreference<string>(
4949
'console.search.recentlySearched',
5050
'[]',
5151
true,
5252
);
5353
const [selectedOptions, setSelectedOptions] = useState(selected);
54-
const [initialSelectOptions, setInitialSelectOptions] = useState<ExtendedSelectOptionProps[]>([]);
55-
const [selectOptions, setSelectOptions] = useState<ExtendedSelectOptionProps[]>(
56-
initialSelectOptions,
57-
);
5854
const [inputValue, setInputValue] = useState<string>('');
5955
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null);
6056
const [activeItemId, setActiveItemId] = useState<string | null>(null);
6157
const placeholderTextDefault = t('public~Resources');
6258
const [placeholder, setPlaceholder] = useState(placeholderTextDefault);
6359
const textInputRef = useRef<HTMLInputElement>();
6460

65-
const resources = allModels
66-
.filter(({ apiGroup, apiVersion, kind, verbs }) => {
67-
// Remove blacklisted items.
68-
if (
69-
blacklistGroups.has(apiGroup) ||
70-
blacklistResources.has(`${apiGroup}/${apiVersion}.${kind}`)
71-
) {
72-
return false;
73-
}
74-
75-
// Only show resources that can be listed.
76-
if (!_.isEmpty(verbs) && !_.includes(verbs, 'list')) {
77-
return false;
61+
const resources = useMemo(() => {
62+
// Pre-compute which group+kind combinations have a preferred version (O(n))
63+
const preferredGroupKinds = new Set<string>();
64+
allModels.forEach((m) => {
65+
if (groupToVersionMap?.[m.apiGroup]?.preferredVersion === m.apiVersion) {
66+
preferredGroupKinds.add(`${m.kind}~${m.apiGroup}`);
7867
}
68+
});
7969

80-
// Only show preferred version for resources in the same API group.
81-
const preferred = (m: K8sKind) =>
82-
groupToVersionMap?.[m.apiGroup]?.preferredVersion === m.apiVersion;
70+
return allModels
71+
.filter(({ apiGroup, apiVersion, kind, verbs }) => {
72+
// Remove blacklisted items.
73+
if (
74+
blacklistGroups.has(apiGroup) ||
75+
blacklistResources.has(`${apiGroup}/${apiVersion}.${kind}`)
76+
) {
77+
return false;
78+
}
8379

84-
const sameGroupKind = (m: K8sKind) =>
85-
m.kind === kind && m.apiGroup === apiGroup && m.apiVersion !== apiVersion;
80+
// Only show resources that can be listed.
81+
if (!_.isEmpty(verbs) && !_.includes(verbs, 'list')) {
82+
return false;
83+
}
8684

87-
return !allModels.find((m) => sameGroupKind(m) && preferred(m));
88-
})
89-
.toOrderedMap()
90-
.sortBy(({ kind, apiGroup }) => `${kind} ${apiGroup}`);
85+
// Only show preferred version for resources in the same API group.
86+
// If a preferred version exists for this group+kind and this isn't it, skip.
87+
const key = `${kind}~${apiGroup}`;
88+
if (
89+
preferredGroupKinds.has(key) &&
90+
groupToVersionMap?.[apiGroup]?.preferredVersion !== apiVersion
91+
) {
92+
return false;
93+
}
9194

92-
useEffect(() => {
93-
const resourcesToOption: SelectOptionProps[] = resources.toArray().map((resource) => {
94-
const reference = referenceForModel(resource);
95-
return { value: reference, children: reference, shortNames: resource.shortNames };
96-
});
97-
setInitialSelectOptions(resourcesToOption);
98-
// eslint-disable-next-line react-hooks/exhaustive-deps
99-
}, []);
95+
return true;
96+
})
97+
.toOrderedMap()
98+
.sortBy(({ kind, apiGroup }) => `${kind} ${apiGroup}`);
99+
}, [allModels, groupToVersionMap]);
100+
101+
const initialSelectOptions = useMemo<ExtendedSelectOptionProps[]>(
102+
() =>
103+
resources.toArray().map((resource) => {
104+
const reference = referenceForModel(resource);
105+
return {
106+
value: reference,
107+
children: reference,
108+
shortNames: resource.shortNames,
109+
// Pre-compute lowercase for filtering so we don't repeat it on every keystroke
110+
searchableText: reference.toLowerCase(),
111+
searchableShortNames: resource.shortNames?.map((s) => s.toLowerCase()),
112+
};
113+
}),
114+
[resources],
115+
);
100116

101117
const filterGroupVersionKind = (resourceList: string[]): string[] => {
102118
return resourceList.filter((resource) => {
@@ -116,40 +132,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
116132

117133
useEffect(() => {
118134
setSelectedOptions(selected);
119-
!_.isEmpty(selected) &&
120-
setRecentSelected(
121-
JSON.stringify(
122-
_.union(
123-
!clearItems ? filterGroupVersionKind(selected.reverse()) : [],
124-
recentSelectedList(recentSelected),
125-
),
126-
),
127-
);
128-
setClearItems(false);
129-
// eslint-disable-next-line react-hooks/exhaustive-deps
130-
}, [selected, setRecentSelected]);
131-
132-
useEffect(() => {
133-
let newSelectOptions: SelectOptionProps[] = initialSelectOptions;
134-
135-
// Filter menu items based on the text input value when one exists
136-
if (inputValue) {
137-
newSelectOptions = initialSelectOptions.filter(
138-
(menuItem) =>
139-
String(menuItem.children).toLowerCase().includes(inputValue.toLowerCase()) ||
140-
menuItem.shortNames?.some((shortName) =>
141-
shortName.toLowerCase().includes(inputValue.toLowerCase()),
142-
),
143-
);
135+
}, [selected]);
144136

145-
// Open the menu when the input value changes and the new value is not empty
146-
if (!isOpen) {
147-
setIsOpen(true);
148-
}
137+
const selectOptions = useMemo(() => {
138+
if (!inputValue) {
139+
return initialSelectOptions;
149140
}
150-
151-
setSelectOptions(newSelectOptions);
152-
// eslint-disable-next-line react-hooks/exhaustive-deps
141+
const lower = inputValue.toLowerCase();
142+
return initialSelectOptions.filter(
143+
(menuItem) =>
144+
menuItem.searchableText?.includes(lower) ||
145+
menuItem.searchableShortNames?.some((shortName) => shortName.includes(lower)),
146+
);
153147
}, [inputValue, initialSelectOptions]);
154148

155149
useEffect(() => {
@@ -161,27 +155,38 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
161155
}, [placeholderTextDefault, selectedOptions, t]);
162156

163157
const createItemId = (value: any) => `resource-dropdown-${value.replace(' ', '-')}`;
158+
159+
// Pre-compute a reference-to-model lookup map (O(1) per lookup instead of O(n))
160+
const referenceToModelMap = useMemo(() => {
161+
const map = new Map<string, K8sKind>();
162+
resources.forEach((r) => map.set(referenceForModel(r), r));
163+
return map;
164+
}, [resources]);
165+
164166
// Track duplicate names so we know when to show the group.
165-
const kinds = resources.groupBy((m) => m.kind);
167+
const kinds = useMemo(() => resources.groupBy((m) => m.kind), [resources]);
166168
const isDup = (kind) => kinds.get(kind).size > 1;
167169

168-
const items = selectOptions.map((option: SelectOptionProps, index) => {
169-
const model = resources.toArray().find((resource: K8sKind) => {
170-
return option.value === referenceForModel(resource);
171-
});
170+
const visibleSelectOptions = selectOptions.slice(0, MAX_VISIBLE_ITEMS);
171+
const items = visibleSelectOptions.map((option: SelectOptionProps, index) => {
172+
const ref = option.value as string;
173+
const model = referenceToModelMap.get(ref);
174+
if (!model) {
175+
return null;
176+
}
172177

173178
return (
174179
<SelectOption
175-
key={referenceForModel(model)}
176-
value={referenceForModel(model)}
180+
key={ref}
181+
value={ref}
177182
hasCheckbox
178-
isSelected={selected.includes(referenceForModel(model))}
183+
isSelected={selected.includes(ref)}
179184
isFocused={focusedItemIndex === index}
180-
id={createItemId(referenceForModel(model))}
185+
id={createItemId(ref)}
181186
>
182187
<span className="co-resource-item">
183188
<span className="co-resource-icon--fixed-width">
184-
<ResourceIcon kind={referenceForModel(model)} />
189+
<ResourceIcon kind={ref} />
185190
</span>
186191
<span className="co-resource-item__resource-name">
187192
<span>
@@ -206,7 +211,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
206211
const recentSearches: JSX.Element[] =
207212
!_.isEmpty(recentSelectedList(recentSelected)) &&
208213
recentSelectedList(recentSelected)
209-
.splice(0, RECENT_SEARCH_ITEMS)
214+
.slice(0, RECENT_SEARCH_ITEMS)
210215
.map((modelRef: K8sResourceKindReference) => {
211216
const model: K8sKind = resources.find((m) => referenceForModel(m) === modelRef);
212217
if (model) {
@@ -245,7 +250,6 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
245250
.filter((item) => item !== null);
246251

247252
const onClear = () => {
248-
setClearItems(true);
249253
setRecentSelected(JSON.stringify([]));
250254
};
251255
const NO_RESULTS = 'no results';
@@ -273,10 +277,11 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
273277
);
274278
options.push(<Divider key={3} className="co-select-group-divider" />);
275279
}
280+
const cleanItems = items.filter(Boolean);
276281
options.push(
277282
<Fragment key="resource-items">
278-
{items.length > 0
279-
? items
283+
{cleanItems.length > 0
284+
? cleanItems
280285
: [
281286
<SelectOption
282287
value={NO_RESULTS}
@@ -286,6 +291,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
286291
{t('public~No results found')}
287292
</SelectOption>,
288293
]}
294+
{selectOptions.length > MAX_VISIBLE_ITEMS && (
295+
<SelectOption
296+
value="type-to-filter"
297+
key="select-multi-typeahead-type-to-filter"
298+
isAriaDisabled={true}
299+
>
300+
{t('public~Showing {{visible}} of {{total}} resources. Type to filter.', {
301+
visible: MAX_VISIBLE_ITEMS,
302+
total: selectOptions.length,
303+
})}
304+
</SelectOption>
305+
)}
289306
</Fragment>,
290307
);
291308
return options;
@@ -318,36 +335,51 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
318335
const onTextInputChange = (_event: FormEvent<HTMLInputElement>, value: string) => {
319336
setInputValue(value);
320337
resetActiveAndFocusedItem();
338+
if (value && !isOpen) {
339+
setIsOpen(true);
340+
}
321341
};
322342

323343
const onSelect = (value: string) => {
324344
if (value && value !== NO_RESULTS) {
325-
setSelectedOptions(
326-
selected.includes(value)
327-
? selected.filter((selection) => selection !== value)
328-
: [...selected, value],
329-
);
345+
const newSelected = selected.includes(value)
346+
? selected.filter((selection) => selection !== value)
347+
: [...selected, value];
348+
setSelectedOptions(newSelected);
330349
onChange(value);
350+
351+
// Update recently used resources
352+
if (!_.isEmpty(newSelected)) {
353+
setRecentSelected(
354+
JSON.stringify(
355+
_.union(
356+
filterGroupVersionKind([...newSelected].reverse()),
357+
recentSelectedList(recentSelected),
358+
),
359+
),
360+
);
361+
}
331362
}
332363

333364
textInputRef.current?.focus();
334365
};
335366

336367
const handleMenuArrowKeys = (key: string) => {
337368
let indexToFocus = 0;
369+
const maxIndex = Math.min(selectOptions.length, MAX_VISIBLE_ITEMS) - 1;
338370

339371
if (!isOpen) {
340372
setIsOpen(true);
341373
}
342374

343-
if (selectOptions.every((option) => option.isDisabled)) {
375+
if (maxIndex < 0 || selectOptions.every((option) => option.isDisabled)) {
344376
return;
345377
}
346378

347379
if (key === 'ArrowUp') {
348380
// When no index is set or at the first index, focus to the last, otherwise decrement focus index
349381
if (focusedItemIndex === null || focusedItemIndex === 0) {
350-
indexToFocus = selectOptions.length - 1;
382+
indexToFocus = maxIndex;
351383
} else {
352384
indexToFocus = focusedItemIndex - 1;
353385
}
@@ -356,14 +388,14 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
356388
while (selectOptions[indexToFocus].isDisabled) {
357389
indexToFocus--;
358390
if (indexToFocus === -1) {
359-
indexToFocus = selectOptions.length - 1;
391+
indexToFocus = maxIndex;
360392
}
361393
}
362394
}
363395

364396
if (key === 'ArrowDown') {
365397
// When no index is set or at the last index, focus to the first, otherwise increment focus index
366-
if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) {
398+
if (focusedItemIndex === null || focusedItemIndex === maxIndex) {
367399
indexToFocus = 0;
368400
} else {
369401
indexToFocus = focusedItemIndex + 1;
@@ -372,7 +404,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
372404
// Skip disabled options
373405
while (selectOptions[indexToFocus].isDisabled) {
374406
indexToFocus++;
375-
if (indexToFocus === selectOptions.length) {
407+
if (indexToFocus > maxIndex) {
376408
indexToFocus = 0;
377409
}
378410
}
@@ -479,6 +511,10 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
479511
interface ExtendedSelectOptionProps extends SelectOptionProps {
480512
/** Searchable short names for the select options */
481513
shortNames?: string[];
514+
/** Pre-computed lowercase text for filtering */
515+
searchableText?: string;
516+
/** Pre-computed lowercase short names for filtering */
517+
searchableShortNames?: string[];
482518
}
483519

484520
const resourceListDropdownStateToProps = ({ k8s }) => ({

frontend/public/locales/en/public.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,7 @@
12571257
"Tech Preview": "Tech Preview",
12581258
"Clear history": "Clear history",
12591259
"Recently used": "Recently used",
1260+
"Showing {{visible}} of {{total}} resources. Type to filter.": "Showing {{visible}} of {{total}} resources. Type to filter.",
12601261
"Clear input value": "Clear input value",
12611262
"{{count}} resource reached quota_one": "{{count}} resource reached quota",
12621263
"{{count}} resource reached quota_other": "{{count}} resource reached quotas",

0 commit comments

Comments
 (0)