Skip to content

Commit 123fc2e

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 123fc2e

File tree

2 files changed

+127
-94
lines changed

2 files changed

+127
-94
lines changed

frontend/public/components/resource-dropdown.tsx

Lines changed: 126 additions & 94 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,70 @@ 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+
const isVisible = (m: K8sKind) =>
63+
!blacklistGroups.has(m.apiGroup) &&
64+
!blacklistResources.has(`${m.apiGroup}/${m.apiVersion}.${m.kind}`) &&
65+
(_.isEmpty(m.verbs) || _.includes(m.verbs, 'list'));
66+
67+
// Pre-compute which group+kind combinations have a visible preferred version (O(n))
68+
const preferredGroupKinds = new Set<string>();
69+
allModels.forEach((m) => {
70+
if (groupToVersionMap?.[m.apiGroup]?.preferredVersion === m.apiVersion && isVisible(m)) {
71+
preferredGroupKinds.add(`${m.kind}~${m.apiGroup}`);
7872
}
73+
});
7974

80-
// Only show preferred version for resources in the same API group.
81-
const preferred = (m: K8sKind) =>
82-
groupToVersionMap?.[m.apiGroup]?.preferredVersion === m.apiVersion;
83-
84-
const sameGroupKind = (m: K8sKind) =>
85-
m.kind === kind && m.apiGroup === apiGroup && m.apiVersion !== apiVersion;
75+
return allModels
76+
.filter((m) => {
77+
if (!isVisible(m)) {
78+
return false;
79+
}
8680

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

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-
}, []);
91+
return true;
92+
})
93+
.toOrderedMap()
94+
.sortBy(({ kind, apiGroup }) => `${kind} ${apiGroup}`);
95+
}, [allModels, groupToVersionMap]);
96+
97+
const initialSelectOptions = useMemo<ExtendedSelectOptionProps[]>(
98+
() =>
99+
resources.toArray().map((resource) => {
100+
const reference = referenceForModel(resource);
101+
return {
102+
value: reference,
103+
children: reference,
104+
shortNames: resource.shortNames,
105+
// Pre-compute lowercase for filtering so we don't repeat it on every keystroke
106+
searchableText: reference.toLowerCase(),
107+
searchableShortNames: resource.shortNames?.map((s) => s.toLowerCase()),
108+
};
109+
}),
110+
[resources],
111+
);
100112

101113
const filterGroupVersionKind = (resourceList: string[]): string[] => {
102114
return resourceList.filter((resource) => {
@@ -116,40 +128,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
116128

117129
useEffect(() => {
118130
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-
);
131+
}, [selected]);
144132

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

155145
useEffect(() => {
@@ -161,27 +151,38 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
161151
}, [placeholderTextDefault, selectedOptions, t]);
162152

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

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

173174
return (
174175
<SelectOption
175-
key={referenceForModel(model)}
176-
value={referenceForModel(model)}
176+
key={ref}
177+
value={ref}
177178
hasCheckbox
178-
isSelected={selected.includes(referenceForModel(model))}
179+
isSelected={selected.includes(ref)}
179180
isFocused={focusedItemIndex === index}
180-
id={createItemId(referenceForModel(model))}
181+
id={createItemId(ref)}
181182
>
182183
<span className="co-resource-item">
183184
<span className="co-resource-icon--fixed-width">
184-
<ResourceIcon kind={referenceForModel(model)} />
185+
<ResourceIcon kind={ref} />
185186
</span>
186187
<span className="co-resource-item__resource-name">
187188
<span>
@@ -206,9 +207,9 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
206207
const recentSearches: JSX.Element[] =
207208
!_.isEmpty(recentSelectedList(recentSelected)) &&
208209
recentSelectedList(recentSelected)
209-
.splice(0, RECENT_SEARCH_ITEMS)
210+
.slice(0, RECENT_SEARCH_ITEMS)
210211
.map((modelRef: K8sResourceKindReference) => {
211-
const model: K8sKind = resources.find((m) => referenceForModel(m) === modelRef);
212+
const model = referenceToModelMap.get(modelRef);
212213
if (model) {
213214
return (
214215
<SelectOption
@@ -245,7 +246,6 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
245246
.filter((item) => item !== null);
246247

247248
const onClear = () => {
248-
setClearItems(true);
249249
setRecentSelected(JSON.stringify([]));
250250
};
251251
const NO_RESULTS = 'no results';
@@ -273,10 +273,11 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
273273
);
274274
options.push(<Divider key={3} className="co-select-group-divider" />);
275275
}
276+
const cleanItems = items.filter(Boolean);
276277
options.push(
277278
<Fragment key="resource-items">
278-
{items.length > 0
279-
? items
279+
{cleanItems.length > 0
280+
? cleanItems
280281
: [
281282
<SelectOption
282283
value={NO_RESULTS}
@@ -286,6 +287,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
286287
{t('public~No results found')}
287288
</SelectOption>,
288289
]}
290+
{selectOptions.length > MAX_VISIBLE_ITEMS && (
291+
<SelectOption
292+
value="type-to-filter"
293+
key="select-multi-typeahead-type-to-filter"
294+
isAriaDisabled={true}
295+
>
296+
{t('public~Showing {{visible}} of {{total}} resources. Type to filter.', {
297+
visible: MAX_VISIBLE_ITEMS,
298+
total: selectOptions.length,
299+
})}
300+
</SelectOption>
301+
)}
289302
</Fragment>,
290303
);
291304
return options;
@@ -318,36 +331,51 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
318331
const onTextInputChange = (_event: FormEvent<HTMLInputElement>, value: string) => {
319332
setInputValue(value);
320333
resetActiveAndFocusedItem();
334+
if (value && !isOpen) {
335+
setIsOpen(true);
336+
}
321337
};
322338

323339
const onSelect = (value: string) => {
324340
if (value && value !== NO_RESULTS) {
325-
setSelectedOptions(
326-
selected.includes(value)
327-
? selected.filter((selection) => selection !== value)
328-
: [...selected, value],
329-
);
341+
const newSelected = selected.includes(value)
342+
? selected.filter((selection) => selection !== value)
343+
: [...selected, value];
344+
setSelectedOptions(newSelected);
330345
onChange(value);
346+
347+
// Update recently used resources
348+
if (!_.isEmpty(newSelected)) {
349+
setRecentSelected(
350+
JSON.stringify(
351+
_.union(
352+
filterGroupVersionKind([...newSelected].reverse()),
353+
recentSelectedList(recentSelected),
354+
),
355+
),
356+
);
357+
}
331358
}
332359

333360
textInputRef.current?.focus();
334361
};
335362

336363
const handleMenuArrowKeys = (key: string) => {
337364
let indexToFocus = 0;
365+
const maxIndex = Math.min(selectOptions.length, MAX_VISIBLE_ITEMS) - 1;
338366

339367
if (!isOpen) {
340368
setIsOpen(true);
341369
}
342370

343-
if (selectOptions.every((option) => option.isDisabled)) {
371+
if (maxIndex < 0 || selectOptions.every((option) => option.isDisabled)) {
344372
return;
345373
}
346374

347375
if (key === 'ArrowUp') {
348376
// When no index is set or at the first index, focus to the last, otherwise decrement focus index
349377
if (focusedItemIndex === null || focusedItemIndex === 0) {
350-
indexToFocus = selectOptions.length - 1;
378+
indexToFocus = maxIndex;
351379
} else {
352380
indexToFocus = focusedItemIndex - 1;
353381
}
@@ -356,14 +384,14 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
356384
while (selectOptions[indexToFocus].isDisabled) {
357385
indexToFocus--;
358386
if (indexToFocus === -1) {
359-
indexToFocus = selectOptions.length - 1;
387+
indexToFocus = maxIndex;
360388
}
361389
}
362390
}
363391

364392
if (key === 'ArrowDown') {
365393
// 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) {
394+
if (focusedItemIndex === null || focusedItemIndex === maxIndex) {
367395
indexToFocus = 0;
368396
} else {
369397
indexToFocus = focusedItemIndex + 1;
@@ -372,7 +400,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
372400
// Skip disabled options
373401
while (selectOptions[indexToFocus].isDisabled) {
374402
indexToFocus++;
375-
if (indexToFocus === selectOptions.length) {
403+
if (indexToFocus > maxIndex) {
376404
indexToFocus = 0;
377405
}
378406
}
@@ -479,6 +507,10 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
479507
interface ExtendedSelectOptionProps extends SelectOptionProps {
480508
/** Searchable short names for the select options */
481509
shortNames?: string[];
510+
/** Pre-computed lowercase text for filtering */
511+
searchableText?: string;
512+
/** Pre-computed lowercase short names for filtering */
513+
searchableShortNames?: string[];
482514
}
483515

484516
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)