From dfb920fb6e2c8039f507de227c9e1cfaa11af0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 17:28:00 +0200 Subject: [PATCH 01/11] fix(catalog-react): prevent EntityKindPicker from double-firing updateFilters on label change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../EntityKindPicker/EntityKindPicker.test.tsx | 2 +- .../src/components/EntityKindPicker/EntityKindPicker.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index 9fa8e6e097..7397c3ee1e 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -140,7 +140,7 @@ describe('', () => { ); expect(updateFilters).toHaveBeenLastCalledWith({ - kind: new EntityKindFilter('group', 'Group'), + kind: new EntityKindFilter('group', 'group'), }); }); diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index 534b8d03b3..c0565851b0 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -17,7 +17,7 @@ import { Select } from '@backstage/core-components'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; import Box from '@material-ui/core/Box'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { EntityKindFilter } from '../../filters'; import { useEntityList } from '../../hooks'; import { filterKinds, useAllKinds } from './kindFilterUtils'; @@ -65,13 +65,16 @@ function useEntityKindFilter(opts: { initialFilter: string }): { const { allKinds, loading, error } = useAllKinds(); const selectedKindLabel = allKinds.get(selectedKind) || selectedKind; + const kindLabelRef = useRef(selectedKindLabel); + kindLabelRef.current = selectedKindLabel; + useEffect(() => { updateFilters({ kind: selectedKind - ? new EntityKindFilter(selectedKind, selectedKindLabel) + ? new EntityKindFilter(selectedKind, kindLabelRef.current) : undefined, }); - }, [selectedKind, selectedKindLabel, updateFilters]); + }, [selectedKind, updateFilters]); return { loading, From 4222c73fd3f851d690b4891ae46e19ce341f6ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 17:29:44 +0200 Subject: [PATCH 02/11] fix(catalog-react): eliminate redundant fetches in useEntityListProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace useAsyncFn + outputState comparison with ref-based fetch dedup and generation counter. Split entity filtering into a synchronous useMemo so frontend-only filter changes are instant. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/hooks/useEntityListProvider.tsx | 307 ++++++++---------- 1 file changed, 132 insertions(+), 175 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index a18575befa..a359a8f2bc 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -31,10 +31,10 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import { useLocation } from 'react-router-dom'; -import useAsyncFn from 'react-use/esm/useAsyncFn'; import useDebounce from 'react-use/esm/useDebounce'; import useMountedState from 'react-use/esm/useMountedState'; import { catalogApiRef } from '../api'; @@ -141,15 +141,11 @@ export const OldEntityListContext = createContext< EntityListContextProps | undefined >(undefined); -type OutputState = { - appliedFilters: EntityFilters; - appliedCursor?: string; - entities: Entity[]; +type BackendState = { backendEntities: Entity[]; pageInfo?: QueryEntitiesResponse['pageInfo']; totalItems?: number; - offset?: number; - limit?: number; + appliedCursor?: string; }; /** @@ -236,187 +232,152 @@ export const EntityListProvider = ( const [offset, setOffset] = useState(initialOffset); const [limit, setLimit] = useState(initialLimit); - const [outputState, setOutputState] = useState>( - () => { - return { - appliedFilters: {} as EntityFilters, - entities: [], - backendEntities: [], - pageInfo: {}, - offset, - limit, - }; - }, - ); + const [backendState, setBackendState] = useState({ + backendEntities: [], + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); - // The main async filter worker. Note that while it has a lot of dependencies - // in terms of its implementation, the triggering only happens (debounced) - // based on the requested filters changing. - const [{ value: resolvedValue, loading, error }, refresh] = useAsyncFn( - async () => { - const kindValue = - requestedFilters.kind?.value?.toLocaleLowerCase('en-US'); - const adjustedFilters = - kindValue === 'user' || kindValue === 'group' - ? { ...requestedFilters, owners: undefined } - : requestedFilters; - const compacted = compact(Object.values(adjustedFilters)); - const entityFilter = reduceEntityFilters(compacted); + // Tracks the params of the last API call so identical requests are + // skipped even when requestedFilters changes (e.g. a label change + // or a frontend-only filter addition). + const lastFetchParamsRef = useRef(undefined); + // Generation counter — only the most recent fetch updates state, + // so out-of-order responses from overlapping requests are discarded. + const fetchGenRef = useRef(0); - if (paginationMode !== 'none') { - if (cursor) { - if (cursor !== outputState.appliedCursor) { - const response = await catalogApi.queryEntities({ - cursor, - limit, - }); - return { - appliedFilters: requestedFilters, - appliedCursor: cursor, - backendEntities: response.items, - entities: response.items.filter(entityFilter), - pageInfo: response.pageInfo, - totalItems: response.totalItems, - }; - } - const entities = outputState.backendEntities.filter(entityFilter); + const refresh = useCallback(async () => { + const kindValue = requestedFilters.kind?.value?.toLocaleLowerCase('en-US'); + const adjustedFilters = + kindValue === 'user' || kindValue === 'group' + ? { ...requestedFilters, owners: undefined } + : requestedFilters; + const compacted = compact(Object.values(adjustedFilters)); + + let fetchParams: unknown; + let doFetch: () => Promise; + + if (paginationMode !== 'none') { + if (cursor) { + fetchParams = { cursor, limit }; + doFetch = async () => { + const response = await catalogApi.queryEntities({ + cursor, + limit, + }); return { - appliedFilters: requestedFilters, - appliedCursor: outputState.appliedCursor, - backendEntities: outputState.backendEntities, - entities, - pageInfo: outputState.pageInfo, - totalItems: outputState.totalItems, - limit: outputState.limit, - offset: outputState.offset, + backendEntities: response.items, + pageInfo: response.pageInfo, + totalItems: response.totalItems, + appliedCursor: cursor, }; - } - + }; + } else { const backendFilter = reduceCatalogFilters(compacted); - const previousBackendFilter = reduceCatalogFilters( - compact(Object.values(outputState.appliedFilters)), - ); - - if ( - (paginationMode === 'offset' && - (outputState.limit !== limit || outputState.offset !== offset)) || - !isEqual(previousBackendFilter, backendFilter) - ) { + fetchParams = { ...backendFilter, limit, offset }; + doFetch = async () => { const response = await catalogApi.queryEntities({ ...backendFilter, limit, offset, }); return { - appliedFilters: requestedFilters, backendEntities: response.items, - entities: response.items.filter(entityFilter), pageInfo: response.pageInfo, totalItems: response.totalItems, - limit, - offset, }; - } - const entities = outputState.backendEntities.filter(entityFilter); - return { - appliedFilters: requestedFilters, - backendEntities: outputState.backendEntities, - entities, - pageInfo: outputState.pageInfo, - totalItems: outputState.totalItems, - limit: outputState.limit, - offset: outputState.offset, }; } - + } else { const backendFilter = reduceBackendCatalogFilters(compacted); const { orderFields } = reduceCatalogFilters(compacted); - const previousBackendFilter = reduceBackendCatalogFilters( - compact(Object.values(outputState.appliedFilters)), - ); - - // TODO(mtlewis): currently entities will never be requested unless - // there's at least one filter, we should allow an initial request - // to happen with no filters. - if (!isEqual(previousBackendFilter, backendFilter)) { - // TODO(timbonicus): should limit fields here, but would need filter - // fields + table columns + fetchParams = { filter: backendFilter, order: orderFields }; + doFetch = async () => { const response = await catalogApi.getEntities({ filter: backendFilter, order: orderFields, }); - const entities = response.items.filter(entityFilter); - return { - appliedFilters: requestedFilters, - backendEntities: response.items, - entities, - totalItems: entities.length, - }; - } - const entities = outputState.backendEntities.filter(entityFilter); - return { - appliedFilters: requestedFilters, - backendEntities: outputState.backendEntities, - entities, - totalItems: entities.length, + return { backendEntities: response.items }; }; - }, - [ - catalogApi, - queryParameters, - requestedFilters, - outputState, - cursor, - paginationMode, - limit, - offset, - ], - { loading: true }, - ); + } - // Slight debounce on the refresh, since (especially on page load) several - // filters will be calling this in rapid succession. - useDebounce(refresh, 10, [requestedFilters, cursor, limit, offset]); - - useEffect(() => { - if (resolvedValue === undefined) { + if (isEqual(fetchParams, lastFetchParamsRef.current)) { return; } - setOutputState(resolvedValue); - if (isMounted()) { - const queryParams = Object.keys(requestedFilters).reduce( - (params, key) => { - const filter = requestedFilters[key as keyof EntityFilters] as - | EntityFilter - | undefined; - if (filter?.toQueryValue) { - params[key] = filter.toQueryValue(); - } - return params; - }, - {} as Record, - ); + lastFetchParamsRef.current = fetchParams; - const oldParams = qs.parse(location.search, { - ignoreQueryPrefix: true, - arrayLimit: 10000, - }); - const newParams = qs.stringify( - { - ...oldParams, - filters: queryParams, - ...(paginationMode === 'none' ? {} : { cursor, limit, offset }), - }, - { addQueryPrefix: true, arrayFormat: 'repeat' }, - ); - const newUrl = `${window.location.pathname}${newParams}`; - // We use direct history manipulation since useSearchParams and - // useNavigate in react-router-dom cause unnecessary extra rerenders. - // Also make sure to replace the state rather than pushing, since we - // don't want there to be back/forward slots for every single filter - // change. - window.history?.replaceState(null, document.title, newUrl); + const gen = ++fetchGenRef.current; + setLoading(true); + setError(undefined); + + try { + const result = await doFetch(); + if (gen === fetchGenRef.current) { + setBackendState(result); + } + } catch (e) { + if (gen === fetchGenRef.current) { + setError(e as Error); + } + } finally { + if (gen === fetchGenRef.current) { + setLoading(false); + } } + }, [catalogApi, requestedFilters, cursor, paginationMode, limit, offset]); + + // Slight debounce on the refresh, since (especially on page load) + // several filters will be calling updateFilters in rapid succession. + useDebounce(refresh, 10, [requestedFilters, cursor, limit, offset]); + + // Frontend filtering — synchronous, no debounce needed. Updates + // instantly when requestedFilters or backendEntities change. + const adjustedFilters = useMemo(() => { + const kindValue = requestedFilters.kind?.value?.toLocaleLowerCase('en-US'); + return kindValue === 'user' || kindValue === 'group' + ? { ...requestedFilters, owners: undefined } + : requestedFilters; + }, [requestedFilters]); + + const entities = useMemo(() => { + const compacted = compact(Object.values(adjustedFilters)); + const entityFilter = reduceEntityFilters(compacted); + return backendState.backendEntities.filter(entityFilter); + }, [adjustedFilters, backendState.backendEntities]); + + // Sync filter state to URL query parameters. We use direct history + // manipulation since useSearchParams and useNavigate in + // react-router-dom cause unnecessary extra rerenders. Also make sure + // to replace the state rather than pushing, since we don't want + // there to be back/forward slots for every single filter change. + useEffect(() => { + if (!isMounted()) { + return; + } + const queryParams = Object.keys(requestedFilters).reduce((params, key) => { + const filter = requestedFilters[key as keyof EntityFilters] as + | EntityFilter + | undefined; + if (filter?.toQueryValue) { + params[key] = filter.toQueryValue(); + } + return params; + }, {} as Record); + + const oldParams = qs.parse(location.search, { + ignoreQueryPrefix: true, + arrayLimit: 10000, + }); + const newParams = qs.stringify( + { + ...oldParams, + filters: queryParams, + ...(paginationMode === 'none' ? {} : { cursor, limit, offset }), + }, + { addQueryPrefix: true, arrayFormat: 'repeat' }, + ); + const newUrl = `${window.location.pathname}${newParams}`; + window.history?.replaceState(null, document.title, newUrl); }, [ cursor, isMounted, @@ -424,7 +385,6 @@ export const EntityListProvider = ( location.search, offset, requestedFilters, - resolvedValue, paginationMode, ]); @@ -454,36 +414,31 @@ export const EntityListProvider = ( [paginationMode], ); - // Use resolvedValue directly when available to avoid an extra render cycle. - // Without this, there's a render where loading has flipped back to false but - // outputState hasn't been updated yet (it syncs via useEffect), causing a - // flash of stale data between the loading state and the new results. - const latestOutput = resolvedValue ?? outputState; - const pageInfo = useMemo(() => { if (paginationMode !== 'cursor') { return undefined; } - const prevCursor = latestOutput.pageInfo?.prevCursor; - const nextCursor = latestOutput.pageInfo?.nextCursor; + const prevCursor = backendState.pageInfo?.prevCursor; + const nextCursor = backendState.pageInfo?.nextCursor; return { prev: prevCursor ? () => setCursor(prevCursor) : undefined, next: nextCursor ? () => setCursor(nextCursor) : undefined, }; - }, [paginationMode, latestOutput.pageInfo]); + }, [paginationMode, backendState.pageInfo]); const value = useMemo( () => ({ - filters: latestOutput.appliedFilters, - entities: latestOutput.entities, - backendEntities: latestOutput.backendEntities, + filters: requestedFilters, + entities, + backendEntities: backendState.backendEntities, updateFilters, queryParameters, loading, error, pageInfo, - totalItems: latestOutput.totalItems, + totalItems: + paginationMode === 'none' ? entities.length : backendState.totalItems, limit, offset, setLimit, @@ -491,15 +446,17 @@ export const EntityListProvider = ( paginationMode, }), [ - latestOutput, + requestedFilters, + entities, + backendState, updateFilters, queryParameters, loading, error, pageInfo, + paginationMode, limit, offset, - paginationMode, setLimit, setOffset, ], From a103f0e6047a64c92dae5ff9012c50f18d5c34d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 17:30:49 +0200 Subject: [PATCH 03/11] test(catalog-react): add regression test for entity list fetch dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/hooks/useEntityListProvider.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index a5fda1bf5b..ef9b77d63f 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -299,6 +299,48 @@ describe('', () => { }); }); + it('does not re-fetch when backend filter params are unchanged', async () => { + const deferred = createDeferred(); + mockCatalogApi.getEntities!.mockReturnValueOnce(deferred); + + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + act(() => { + result.current.updateFilters({ + kind: new EntityKindFilter('component', 'component'), + }); + }); + + await waitFor(() => { + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + }); + + // While first fetch is in flight, fire more updateFilters calls + // that produce the same backend filter (kind=component). + act(() => { + result.current.updateFilters({ + kind: new EntityKindFilter('component', 'Component'), + }); + }); + act(() => { + result.current.updateFilters({ + user: EntityUserFilter.all(), + }); + }); + + await act(async () => { + deferred.resolve({ items: entities }); + }); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBe(2); + }); + + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + }); + it('returns an error on catalogApi failure', async () => { const { result } = renderHook(() => useEntityList(), { wrapper: createWrapper({ pagination }), From 7c205457b65f7f0badd9dcbe437fc3c1a5bdcb78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 17:31:38 +0200 Subject: [PATCH 04/11] Add changeset for entity list triple-fetch fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/fix-entity-list-triple-fetch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-entity-list-triple-fetch.md diff --git a/.changeset/fix-entity-list-triple-fetch.md b/.changeset/fix-entity-list-triple-fetch.md new file mode 100644 index 0000000000..62b30d881c --- /dev/null +++ b/.changeset/fix-entity-list-triple-fetch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Fixed redundant API calls during entity list initialization. Filter components that register their initial state in quick succession (e.g. `EntityKindPicker`, `UserListPicker`, `EntityTagPicker`) no longer trigger multiple identical fetches. Frontend-only filter changes such as toggling the user list are now applied synchronously without a network round-trip. From 4694eb16e3dd831dfa36f483097d54f30a93f9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 17:37:35 +0200 Subject: [PATCH 05/11] =?UTF-8?q?Revert=20EntityKindPicker=20ref=20?= =?UTF-8?q?=E2=80=94=20provider=20dedup=20handles=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label is read by CatalogTable for the table title (e.g. "All Components (42)"), so suppressing the label update would cause a visible regression. The provider's ref-based fetch dedup already prevents the redundant API call when only the label changes, so the second updateFilters call is harmless. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../EntityKindPicker/EntityKindPicker.test.tsx | 2 +- .../src/components/EntityKindPicker/EntityKindPicker.tsx | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index 7397c3ee1e..9fa8e6e097 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -140,7 +140,7 @@ describe('', () => { ); expect(updateFilters).toHaveBeenLastCalledWith({ - kind: new EntityKindFilter('group', 'group'), + kind: new EntityKindFilter('group', 'Group'), }); }); diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index c0565851b0..534b8d03b3 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -17,7 +17,7 @@ import { Select } from '@backstage/core-components'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; import Box from '@material-ui/core/Box'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { EntityKindFilter } from '../../filters'; import { useEntityList } from '../../hooks'; import { filterKinds, useAllKinds } from './kindFilterUtils'; @@ -65,16 +65,13 @@ function useEntityKindFilter(opts: { initialFilter: string }): { const { allKinds, loading, error } = useAllKinds(); const selectedKindLabel = allKinds.get(selectedKind) || selectedKind; - const kindLabelRef = useRef(selectedKindLabel); - kindLabelRef.current = selectedKindLabel; - useEffect(() => { updateFilters({ kind: selectedKind - ? new EntityKindFilter(selectedKind, kindLabelRef.current) + ? new EntityKindFilter(selectedKind, selectedKindLabel) : undefined, }); - }, [selectedKind, updateFilters]); + }, [selectedKind, selectedKindLabel, updateFilters]); return { loading, From 779749fbd78b1da4d5ea17dffc0905d85e71a697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 17:57:33 +0200 Subject: [PATCH 06/11] Guard against empty-filter fetch on mount and premature URL sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lastFetchParamsRef starts as undefined, so the initial debounce would have fetched with an empty filter (returning every entity in the catalog). Guard against this by skipping the fetch when no filter components have registered yet. Also skip the URL sync effect until at least one filter has been set, to avoid briefly clearing filter query params from the URL bar before filter components initialize. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../catalog-react/src/hooks/useEntityListProvider.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index a359a8f2bc..c6a35be367 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -301,6 +301,13 @@ export const EntityListProvider = ( }; } + // No filters registered yet — wait for filter components to + // call updateFilters before making the first request. + if (compacted.length === 0 && lastFetchParamsRef.current === undefined) { + setLoading(false); + return; + } + if (isEqual(fetchParams, lastFetchParamsRef.current)) { return; } @@ -351,7 +358,7 @@ export const EntityListProvider = ( // to replace the state rather than pushing, since we don't want // there to be back/forward slots for every single filter change. useEffect(() => { - if (!isMounted()) { + if (!isMounted() || Object.keys(requestedFilters).length === 0) { return; } const queryParams = Object.keys(requestedFilters).reduce((params, key) => { From ffa59be219f7b947a34bbd173ecebf42c73022af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 20:24:34 +0200 Subject: [PATCH 07/11] Revert lastFetchParamsRef on failed fetch to allow retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/src/hooks/useEntityListProvider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index c6a35be367..66043ad237 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -311,6 +311,7 @@ export const EntityListProvider = ( if (isEqual(fetchParams, lastFetchParamsRef.current)) { return; } + const prevFetchParams = lastFetchParamsRef.current; lastFetchParamsRef.current = fetchParams; const gen = ++fetchGenRef.current; @@ -324,6 +325,7 @@ export const EntityListProvider = ( } } catch (e) { if (gen === fetchGenRef.current) { + lastFetchParamsRef.current = prevFetchParams; setError(e as Error); } } finally { From 9e880b70fd2d9a316497b68b341ca76b0f0a1e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 20 May 2026 22:42:49 +0200 Subject: [PATCH 08/11] Deduplicate adjustedFilters computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the adjustedFilters useMemo above refresh and reuse it in both the fetch callback and the frontend filtering memo. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/hooks/useEntityListProvider.tsx | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 66043ad237..ced298b17d 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -246,12 +246,16 @@ export const EntityListProvider = ( // so out-of-order responses from overlapping requests are discarded. const fetchGenRef = useRef(0); - const refresh = useCallback(async () => { + // Adjusted filters remove the owners filter for user/group kinds, + // since ownership is not meaningful for those entity types. + const adjustedFilters = useMemo(() => { const kindValue = requestedFilters.kind?.value?.toLocaleLowerCase('en-US'); - const adjustedFilters = - kindValue === 'user' || kindValue === 'group' - ? { ...requestedFilters, owners: undefined } - : requestedFilters; + return kindValue === 'user' || kindValue === 'group' + ? { ...requestedFilters, owners: undefined } + : requestedFilters; + }, [requestedFilters]); + + const refresh = useCallback(async () => { const compacted = compact(Object.values(adjustedFilters)); let fetchParams: unknown; @@ -333,21 +337,14 @@ export const EntityListProvider = ( setLoading(false); } } - }, [catalogApi, requestedFilters, cursor, paginationMode, limit, offset]); + }, [catalogApi, adjustedFilters, cursor, paginationMode, limit, offset]); // Slight debounce on the refresh, since (especially on page load) // several filters will be calling updateFilters in rapid succession. - useDebounce(refresh, 10, [requestedFilters, cursor, limit, offset]); + useDebounce(refresh, 10, [adjustedFilters, cursor, limit, offset]); // Frontend filtering — synchronous, no debounce needed. Updates // instantly when requestedFilters or backendEntities change. - const adjustedFilters = useMemo(() => { - const kindValue = requestedFilters.kind?.value?.toLocaleLowerCase('en-US'); - return kindValue === 'user' || kindValue === 'group' - ? { ...requestedFilters, owners: undefined } - : requestedFilters; - }, [requestedFilters]); - const entities = useMemo(() => { const compacted = compact(Object.values(adjustedFilters)); const entityFilter = reduceEntityFilters(compacted); From 720fb542d9f693d6d47d954c19b64b8a058e3c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 21 May 2026 09:55:57 +0200 Subject: [PATCH 09/11] cleanup(catalog-react): remove dead appliedCursor field, fix cursor-mode guard, inline getPaginationMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/hooks/useEntityListProvider.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index ced298b17d..01a02da7a3 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -145,7 +145,6 @@ type BackendState = { backendEntities: Entity[]; pageInfo?: QueryEntitiesResponse['pageInfo']; totalItems?: number; - appliedCursor?: string; }; /** @@ -174,16 +173,12 @@ export const EntityListProvider = ( // update of the URL or two catalog sidebar links with different catalog filters. const location = useLocation(); - const getPaginationMode = (): PaginationMode => { - if (props.pagination === true) { - return 'cursor'; - } - return typeof props.pagination === 'object' - ? props.pagination.mode ?? 'cursor' - : 'none'; - }; - - const paginationMode = getPaginationMode(); + let paginationMode: PaginationMode = 'none'; + if (props.pagination === true) { + paginationMode = 'cursor'; + } else if (typeof props.pagination === 'object') { + paginationMode = props.pagination.mode ?? 'cursor'; + } const paginationLimit = typeof props.pagination === 'object' ? props.pagination.limit ?? 20 : 20; @@ -273,7 +268,6 @@ export const EntityListProvider = ( backendEntities: response.items, pageInfo: response.pageInfo, totalItems: response.totalItems, - appliedCursor: cursor, }; }; } else { @@ -307,7 +301,13 @@ export const EntityListProvider = ( // No filters registered yet — wait for filter components to // call updateFilters before making the first request. - if (compacted.length === 0 && lastFetchParamsRef.current === undefined) { + // Exception: a cursor in the URL is a self-contained page reference + // that doesn't need filter state to be valid. + if ( + compacted.length === 0 && + lastFetchParamsRef.current === undefined && + !cursor + ) { setLoading(false); return; } From 77e7ad4c7dbf1f58fbf7785d28a31d5c12591813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 21 May 2026 10:12:51 +0200 Subject: [PATCH 10/11] fix(catalog-react): clear fetch dedup ref on failure instead of reverting to previous params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/src/hooks/useEntityListProvider.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 01a02da7a3..f49f5d1993 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -315,7 +315,6 @@ export const EntityListProvider = ( if (isEqual(fetchParams, lastFetchParamsRef.current)) { return; } - const prevFetchParams = lastFetchParamsRef.current; lastFetchParamsRef.current = fetchParams; const gen = ++fetchGenRef.current; @@ -329,7 +328,10 @@ export const EntityListProvider = ( } } catch (e) { if (gen === fetchGenRef.current) { - lastFetchParamsRef.current = prevFetchParams; + // Clear the ref so the same params can be retried, and so + // a response discarded due to a concurrent request (gen mismatch) + // doesn't permanently block fetching those params again. + lastFetchParamsRef.current = undefined; setError(e as Error); } } finally { From 392c1b2851c65bf16da0b8e38078ca79461bd683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 21 May 2026 10:18:24 +0200 Subject: [PATCH 11/11] =?UTF-8?q?refactor(catalog-react):=20drop=20useCall?= =?UTF-8?q?back=20on=20refresh=20=E2=80=94=20useDebounce=20uses=20a=20ref?= =?UTF-8?q?=20internally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/src/hooks/useEntityListProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index f49f5d1993..db7b48822a 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -250,7 +250,7 @@ export const EntityListProvider = ( : requestedFilters; }, [requestedFilters]); - const refresh = useCallback(async () => { + const refresh = async () => { const compacted = compact(Object.values(adjustedFilters)); let fetchParams: unknown; @@ -339,7 +339,7 @@ export const EntityListProvider = ( setLoading(false); } } - }, [catalogApi, adjustedFilters, cursor, paginationMode, limit, offset]); + }; // Slight debounce on the refresh, since (especially on page load) // several filters will be calling updateFilters in rapid succession.