From 9784246c1664a91d1bb7ad4acdd6701adc8f4d8c Mon Sep 17 00:00:00 2001 From: stefanonardo Date: Thu, 9 Apr 2026 14:20:19 +0200 Subject: [PATCH] OCPBUGS-81519: Fix Search page state mutation and unnecessary component remounts Fix Set reference copy bug in resource selection handlers, replace filter-embedded component keys with stable keys to prevent WebSocket watch teardown, and add memo/debounce to ResourceList to avoid expensive re-renders during typing. Co-Authored-By: Claude Opus 4.6 --- .../data-view/useConsoleDataViewFilters.ts | 35 ++++++- frontend/public/components/search.tsx | 91 +++++++++++-------- 2 files changed, 89 insertions(+), 37 deletions(-) diff --git a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts index 531b22e94d8..19f2c6e5ae7 100644 --- a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts +++ b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useDataViewFilters } from '@patternfly/react-data-view'; import { useSearchParams } from 'react-router'; import { useExactSearch } from '@console/app/src/components/user-preferences/search/useExactSearch'; @@ -38,6 +38,39 @@ export const useConsoleDataViewFilters = < setSearchParams, }); + // Sync URL search params → internal filter state. + // useDataViewFilters only reads searchParams on mount (empty deps useEffect). + // This effect ensures filters stay in sync when the URL changes externally + // (e.g., the Search page updating query params without remounting). + const filtersRef = useRef(filters); + filtersRef.current = filters; + useEffect(() => { + const updates: Partial = {}; + let hasChanges = false; + for (const key of Object.keys(filtersRef.current)) { + const currentValue = filtersRef.current[key]; + if (Array.isArray(currentValue)) { + const urlValues = searchParams.getAll(key); + if ( + urlValues.length !== currentValue.length || + urlValues.some((v, i) => v !== currentValue[i]) + ) { + updates[key] = urlValues; + hasChanges = true; + } + } else { + const urlValue = searchParams.get(key) ?? ''; + if (urlValue !== currentValue) { + updates[key] = urlValue; + hasChanges = true; + } + } + } + if (hasChanges) { + onSetFilters(updates as TFilters); + } + }, [searchParams, onSetFilters]); + const filteredData = useMemo( () => data?.filter((resource) => { diff --git a/frontend/public/components/search.tsx b/frontend/public/components/search.tsx index cff0a84364d..6039aa40da6 100644 --- a/frontend/public/components/search.tsx +++ b/frontend/public/components/search.tsx @@ -1,6 +1,6 @@ import * as _ from 'lodash'; import type { FC, MouseEvent } from 'react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, memo } from 'react'; import { DocumentTitle } from '@console/shared/src/components/document-title/DocumentTitle'; import { useDebounceCallback } from '@console/shared/src/hooks/useDebounceCallback'; import { useTranslation } from 'react-i18next'; @@ -51,37 +51,51 @@ import { useActivePerspective } from '@console/dynamic-plugin-sdk/src/perspectiv import { useActiveNamespace, useK8sModel } from '@console/dynamic-plugin-sdk/src/lib-core'; import { ALL_NAMESPACES_KEY } from '@console/shared/src/constants'; -const ResourceList = ({ kind, mock, namespace, selector, nameFilter }) => { - const { plural } = useParams<{ plural?: string }>(); - const [kindObj] = useK8sModel(kind || plural); - const resourceListPageExtensions = useExtensions(isResourceListPage); - if (!kindObj) { - return ; - } +const ResourceList = memo( + ({ + kind, + mock, + namespace, + selector, + nameFilter, + }: { + kind: string; + mock: boolean; + namespace: string; + selector: any; + nameFilter: string; + }) => { + const { plural } = useParams<{ plural?: string }>(); + const [kindObj] = useK8sModel(kind || plural); + const resourceListPageExtensions = useExtensions(isResourceListPage); + if (!kindObj) { + return ; + } - const componentLoader = getResourceListPages(resourceListPageExtensions).get( - referenceForModel(kindObj), - () => Promise.resolve(DefaultPage), - ); - const ns = kindObj.namespaced ? namespace : undefined; + const componentLoader = getResourceListPages(resourceListPageExtensions).get( + referenceForModel(kindObj), + () => Promise.resolve(DefaultPage), + ); + const ns = kindObj.namespaced ? namespace : undefined; - return ( - - ); -}; + return ( + + ); + }, +); const SearchPage_: FC = (props) => { const { setQueryArgument, removeQueryArguments } = useQueryParamsMutator(); @@ -120,10 +134,12 @@ const SearchPage_: FC = (props) => { const validTags = _.reject(tags, (tag) => requirementFromString(tag) === undefined); setLabelFilter(validTags); setTypeaheadNameFilter(name || ''); + setDebouncedNameFilter(name || ''); }, [location.search]); const debouncedNameFilterCallback = useDebounceCallback((nameFilter: string) => { setDebouncedNameFilter(nameFilter); + setQueryArgument('name', nameFilter); }, 300); useEffect(() => { @@ -131,7 +147,7 @@ const SearchPage_: FC = (props) => { }, [typeaheadNameFilter, debouncedNameFilterCallback]); const updateSelectedItems = (selection: string) => { - const updateItems = selectedItems; + const updateItems = new Set(selectedItems); fireTelemetryEvent('search-resource-selected', { resource: selection, }); @@ -141,7 +157,7 @@ const SearchPage_: FC = (props) => { }; const updateNewItems = (_filter: string, { key }: ToolbarLabel) => { - const updateItems = selectedItems; + const updateItems = new Set(selectedItems); updateItems.has(key) ? updateItems.delete(key) : updateItems.add(key); setSelectedItems(updateItems); setQueryArgument('kind', [...updateItems].join(',')); @@ -154,6 +170,7 @@ const SearchPage_: FC = (props) => { const clearNameFilter = () => { setTypeaheadNameFilter(''); + setDebouncedNameFilter(''); setQueryArgument('name', ''); }; @@ -165,6 +182,7 @@ const SearchPage_: FC = (props) => { const clearAll = () => { setSelectedItems(new Set([])); setTypeaheadNameFilter(''); + setDebouncedNameFilter(''); setLabelFilter([]); removeQueryArguments('kind', 'name', 'q'); }; @@ -188,7 +206,6 @@ const SearchPage_: FC = (props) => { const updateNameFilter = (value: string) => { setTypeaheadNameFilter(value); - setQueryArgument('name', value); }; const updateLabelFilter = (value: string, endOfString: boolean) => { @@ -239,6 +256,8 @@ const SearchPage_: FC = (props) => { return model.labelKey ? t(model.labelKey) : model.label; }; + const selector = useMemo(() => selectorFromString(labelFilter.join(',')), [labelFilter]); + return ( <> {t('public~Search')} @@ -338,11 +357,11 @@ const SearchPage_: FC = (props) => { {!isCollapsed && ( )}