Skip to content

Commit 536dd7f

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 536dd7f

2 files changed

Lines changed: 109 additions & 66 deletions

File tree

frontend/public/components/resource-dropdown.tsx

Lines changed: 108 additions & 66 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 = 200;
3132

3233
// Blacklist known duplicate resources.
3334
const blacklistGroups = ImmutableSet([
@@ -51,52 +52,68 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
5152
true,
5253
);
5354
const [selectedOptions, setSelectedOptions] = useState(selected);
54-
const [initialSelectOptions, setInitialSelectOptions] = useState<ExtendedSelectOptionProps[]>([]);
55-
const [selectOptions, setSelectOptions] = useState<ExtendedSelectOptionProps[]>(
56-
initialSelectOptions,
57-
);
5855
const [inputValue, setInputValue] = useState<string>('');
5956
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null);
6057
const [activeItemId, setActiveItemId] = useState<string | null>(null);
6158
const placeholderTextDefault = t('public~Resources');
6259
const [placeholder, setPlaceholder] = useState(placeholderTextDefault);
6360
const textInputRef = useRef<HTMLInputElement>();
6461

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;
62+
const resources = useMemo(() => {
63+
// Pre-compute which group+kind combinations have a preferred version (O(n))
64+
const preferredGroupKinds = new Set<string>();
65+
allModels.forEach((m) => {
66+
if (groupToVersionMap?.[m.apiGroup]?.preferredVersion === m.apiVersion) {
67+
preferredGroupKinds.add(`${m.kind}~${m.apiGroup}`);
7868
}
69+
});
7970

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

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

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

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

101118
const filterGroupVersionKind = (resourceList: string[]): string[] => {
102119
return resourceList.filter((resource) => {
@@ -129,28 +146,25 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
129146
// eslint-disable-next-line react-hooks/exhaustive-deps
130147
}, [selected, setRecentSelected]);
131148

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-
);
144-
145-
// Open the menu when the input value changes and the new value is not empty
146-
if (!isOpen) {
147-
setIsOpen(true);
148-
}
149+
const selectOptions = useMemo(() => {
150+
if (!inputValue) {
151+
return initialSelectOptions;
149152
}
153+
const lower = inputValue.toLowerCase();
154+
return initialSelectOptions.filter(
155+
(menuItem) =>
156+
menuItem.searchableText?.includes(lower) ||
157+
menuItem.searchableShortNames?.some((shortName) => shortName.includes(lower)),
158+
);
159+
}, [inputValue, initialSelectOptions]);
150160

151-
setSelectOptions(newSelectOptions);
161+
// Open the menu when the input value changes and the new value is not empty
162+
useEffect(() => {
163+
if (inputValue && !isOpen) {
164+
setIsOpen(true);
165+
}
152166
// eslint-disable-next-line react-hooks/exhaustive-deps
153-
}, [inputValue, initialSelectOptions]);
167+
}, [inputValue]);
154168

155169
useEffect(() => {
156170
setPlaceholder(
@@ -161,27 +175,37 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
161175
}, [placeholderTextDefault, selectedOptions, t]);
162176

163177
const createItemId = (value: any) => `resource-dropdown-${value.replace(' ', '-')}`;
178+
179+
// Pre-compute a reference-to-model lookup map (O(1) per lookup instead of O(n))
180+
const referenceToModelMap = useMemo(() => {
181+
const map = new Map<string, K8sKind>();
182+
resources.forEach((r) => map.set(referenceForModel(r), r));
183+
return map;
184+
}, [resources]);
185+
164186
// Track duplicate names so we know when to show the group.
165-
const kinds = resources.groupBy((m) => m.kind);
187+
const kinds = useMemo(() => resources.groupBy((m) => m.kind), [resources]);
166188
const isDup = (kind) => kinds.get(kind).size > 1;
167189

168190
const items = selectOptions.map((option: SelectOptionProps, index) => {
169-
const model = resources.toArray().find((resource: K8sKind) => {
170-
return option.value === referenceForModel(resource);
171-
});
191+
const ref = option.value as string;
192+
const model = referenceToModelMap.get(ref);
193+
if (!model) {
194+
return null;
195+
}
172196

173197
return (
174198
<SelectOption
175-
key={referenceForModel(model)}
176-
value={referenceForModel(model)}
199+
key={ref}
200+
value={ref}
177201
hasCheckbox
178-
isSelected={selected.includes(referenceForModel(model))}
202+
isSelected={selected.includes(ref)}
179203
isFocused={focusedItemIndex === index}
180-
id={createItemId(referenceForModel(model))}
204+
id={createItemId(ref)}
181205
>
182206
<span className="co-resource-item">
183207
<span className="co-resource-icon--fixed-width">
184-
<ResourceIcon kind={referenceForModel(model)} />
208+
<ResourceIcon kind={ref} />
185209
</span>
186210
<span className="co-resource-item__resource-name">
187211
<span>
@@ -206,7 +230,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
206230
const recentSearches: JSX.Element[] =
207231
!_.isEmpty(recentSelectedList(recentSelected)) &&
208232
recentSelectedList(recentSelected)
209-
.splice(0, RECENT_SEARCH_ITEMS)
233+
.slice(0, RECENT_SEARCH_ITEMS)
210234
.map((modelRef: K8sResourceKindReference) => {
211235
const model: K8sKind = resources.find((m) => referenceForModel(m) === modelRef);
212236
if (model) {
@@ -273,10 +297,12 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
273297
);
274298
options.push(<Divider key={3} className="co-select-group-divider" />);
275299
}
300+
const visibleItems =
301+
items.length > MAX_VISIBLE_ITEMS ? items.slice(0, MAX_VISIBLE_ITEMS) : items;
276302
options.push(
277303
<Fragment key="resource-items">
278-
{items.length > 0
279-
? items
304+
{visibleItems.length > 0
305+
? visibleItems
280306
: [
281307
<SelectOption
282308
value={NO_RESULTS}
@@ -286,6 +312,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
286312
{t('public~No results found')}
287313
</SelectOption>,
288314
]}
315+
{items.length > MAX_VISIBLE_ITEMS && (
316+
<SelectOption
317+
value="type-to-filter"
318+
key="select-multi-typeahead-type-to-filter"
319+
isAriaDisabled={true}
320+
>
321+
{t('public~Showing {{visible}} of {{total}} resources. Type to filter.', {
322+
visible: MAX_VISIBLE_ITEMS,
323+
total: items.length,
324+
})}
325+
</SelectOption>
326+
)}
289327
</Fragment>,
290328
);
291329
return options;
@@ -479,6 +517,10 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
479517
interface ExtendedSelectOptionProps extends SelectOptionProps {
480518
/** Searchable short names for the select options */
481519
shortNames?: string[];
520+
/** Pre-computed lowercase text for filtering */
521+
searchableText?: string;
522+
/** Pre-computed lowercase short names for filtering */
523+
searchableShortNames?: string[];
482524
}
483525

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