From d8757b158c4bc087c6679f1d2719775b35b39b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 21 May 2026 17:02:49 +0200 Subject: [PATCH 01/12] catalog-react: split entity list and count into parallel requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reapply the split-count concept on top of the refactored useEntityListProvider (from #34324). The list fetch now uses totalItems: 'exclude' to skip the expensive count, and a separate useAsyncFn fetches the count only when filters change — page navigation no longer re-runs the count query. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/catalog-react-split-count.md | 5 + .../src/hooks/useEntityListProvider.test.tsx | 176 ++++++++++-------- .../src/hooks/useEntityListProvider.tsx | 36 +++- 3 files changed, 141 insertions(+), 76 deletions(-) create mode 100644 .changeset/catalog-react-split-count.md diff --git a/.changeset/catalog-react-split-count.md b/.changeset/catalog-react-split-count.md new file mode 100644 index 0000000000..c48c806c7b --- /dev/null +++ b/.changeset/catalog-react-split-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +The entity list provider now fetches the entity list and the total count as two separate parallel requests when using cursor or offset pagination. The list query skips the expensive count computation (using `totalItems: 'exclude'`), so the table populates immediately. The count arrives asynchronously and updates the title. This significantly improves perceived latency for catalog page loads on large catalogs. diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index ef9b77d63f..02d5ef3960 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -299,48 +299,6 @@ 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 }), @@ -512,11 +470,11 @@ describe('', () => { await waitFor(() => { expect(mockCatalogApi.getEntities).not.toHaveBeenCalledTimes(1); expect(result.current.entities.length).toBe(1); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: 'component' }, limit, orderFields, + totalItems: 'exclude', fullTextFilter: { term: '2', fields: [ @@ -539,11 +497,11 @@ describe('', () => { }); expect(result.current.entities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: 'component' }, limit, orderFields, + totalItems: 'exclude', }); }); @@ -564,8 +522,16 @@ describe('', () => { await waitFor(() => { expect(result.current.backendEntities.length).toBe(2); expect(result.current.entities.length).toBe(1); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + kind: 'component', + 'relations.ownedBy': ownershipEntityRefs, + }, + totalItems: 'exclude', + }), + ); }); it('applies frontend-only filters without refetching', async () => { @@ -578,6 +544,11 @@ describe('', () => { expect(result.current.filters.kind?.value).toBe('component'); }); + // Record the number of list calls (totalItems: 'exclude') after init + const listCallsAfterInit = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.totalItems === 'exclude').length; + act(() => result.current.updateFilters({ user: EntityUserFilter.all(), @@ -588,7 +559,13 @@ describe('', () => { expect(result.current.filters.user?.value).toBe('all'); expect(result.current.entities.length).toBe(2); }); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + // EntityUserFilter.all() doesn't change the backend filter, so no + // additional list call should fire (count effect fires, but not + // the list fetch). + const listCallsAfterUpdate = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.totalItems === 'exclude').length; + expect(listCallsAfterUpdate).toBe(listCallsAfterInit); }); it('resolves query param filter values', async () => { @@ -618,7 +595,6 @@ describe('', () => { await waitFor(() => { expect(result.current.entities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); }); act(() => @@ -631,8 +607,17 @@ describe('', () => { expect(result.current.entities.length).toBe(1); }); + // Verify the list call with the owned filter was made await waitFor(() => { - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + kind: 'component', + 'relations.ownedBy': ownershipEntityRefs, + }, + totalItems: 'exclude', + }), + ); }); }); @@ -645,7 +630,6 @@ describe('', () => { expect(result.current.backendEntities.length).toBeGreaterThan(0); }); expect(result.current.backendEntities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); await act(async () => { result.current.updateFilters({ @@ -655,14 +639,13 @@ describe('', () => { }); await waitFor(() => { - expect(mockCatalogApi.queryEntities).toHaveBeenNthCalledWith(2, { + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: 'api', 'spec.type': ['service'] }, limit, orderFields, + totalItems: 'exclude', }); }); - - expect(result.current.totalItems).toBe(10); }); it('returns an error on catalogApi failure', async () => { @@ -675,6 +658,9 @@ describe('', () => { }); expect(result.current.backendEntities.length).toBe(2); + // The count effect fires first (consuming one rejection), then the + // list call fires. Both must reject for the error to surface. + mockCatalogApi.queryEntities!.mockRejectedValueOnce('error'); mockCatalogApi.queryEntities!.mockRejectedValueOnce('error'); act(() => { result.current.updateFilters({ @@ -726,6 +712,7 @@ describe('', () => { expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ cursor: 'nextCursor', limit, + totalItems: 'exclude', }); }); }); @@ -750,6 +737,7 @@ describe('', () => { expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ cursor: 'prevCursor', limit, + totalItems: 'exclude', }); }); }); @@ -836,12 +824,12 @@ describe(``, () => { await waitFor(() => { expect(mockCatalogApi.getEntities).not.toHaveBeenCalledTimes(1); expect(result.current.entities.length).toBe(1); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: 'component' }, limit, offset: 0, orderFields, + totalItems: 'exclude', fullTextFilter: { term: '2', fields: [ @@ -864,12 +852,12 @@ describe(``, () => { }); expect(result.current.entities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: 'component' }, limit, offset: 0, orderFields, + totalItems: 'exclude', }); }); @@ -890,8 +878,16 @@ describe(``, () => { await waitFor(() => { expect(result.current.backendEntities.length).toBe(2); expect(result.current.entities.length).toBe(1); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + kind: 'component', + 'relations.ownedBy': ownershipEntityRefs, + }, + totalItems: 'exclude', + }), + ); }); it('applies frontend-only filters without refetching', async () => { @@ -904,6 +900,11 @@ describe(``, () => { expect(result.current.filters.kind?.value).toBe('component'); }); + // Record the number of list calls (totalItems: 'exclude') after init + const listCallsAfterInit = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.totalItems === 'exclude').length; + act(() => result.current.updateFilters({ user: EntityUserFilter.all(), @@ -914,7 +915,13 @@ describe(``, () => { expect(result.current.filters.user?.value).toBe('all'); expect(result.current.entities.length).toBe(2); }); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + // EntityUserFilter.all() doesn't change the backend filter, so no + // additional list call should fire (count effect fires, but not + // the list fetch). + const listCallsAfterUpdate = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.totalItems === 'exclude').length; + expect(listCallsAfterUpdate).toBe(listCallsAfterInit); }); it('resolves query param filter values', async () => { @@ -944,7 +951,6 @@ describe(``, () => { await waitFor(() => { expect(result.current.entities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); }); act(() => @@ -957,21 +963,40 @@ describe(``, () => { expect(result.current.entities.length).toBe(1); }); + // Verify the list call with the owned filter was made await waitFor(() => { - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + kind: 'component', + 'relations.ownedBy': ownershipEntityRefs, + }, + totalItems: 'exclude', + }), + ); }); + // Record list call count before setting the same filter again + const listCallsBefore = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.totalItems === 'exclude').length; + act(() => result.current.updateFilters({ user: EntityUserFilter.owned(ownershipEntityRefs), }), ); - await expect(() => - waitFor(() => { - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3); - }), - ).rejects.toThrow(); + // Wait for any pending effects to settle + await waitFor(() => { + expect(result.current.entities.length).toBe(1); + }); + + // Setting the same filter again should not trigger additional list calls + const listCallsAfter = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.totalItems === 'exclude').length; + expect(listCallsAfter).toBe(listCallsBefore); }); it('fetch when limit change', async () => { @@ -981,18 +1006,20 @@ describe(``, () => { await waitFor(() => { expect(result.current.entities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); }); act(() => result.current.setLimit(50)); + // setLimit does not change requestedFilters, so no extra count call. + // Only the debounced list call fires. await waitFor(() => { - expect(result.current.entities.length).toBe(2); - }); - - await waitFor(() => { - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(2); expect(result.current.limit).toEqual(50); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 50, + totalItems: 'exclude', + }), + ); }); }); @@ -1005,7 +1032,6 @@ describe(``, () => { expect(result.current.backendEntities.length).toBeGreaterThan(0); }); expect(result.current.backendEntities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); act(() => { result.current.updateFilters({ @@ -1015,11 +1041,12 @@ describe(``, () => { }); await waitFor(() => { - expect(mockCatalogApi.queryEntities).toHaveBeenNthCalledWith(2, { + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: 'api', 'spec.type': ['service'] }, limit, offset: 0, orderFields, + totalItems: 'exclude', }); }); }); @@ -1033,7 +1060,6 @@ describe(``, () => { expect(result.current.backendEntities.length).toBeGreaterThan(0); }); expect(result.current.backendEntities.length).toBe(2); - expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); act(() => { result.current.setOffset!(5); @@ -1041,11 +1067,12 @@ describe(``, () => { }); await waitFor(() => { - expect(mockCatalogApi.queryEntities).toHaveBeenNthCalledWith(2, { + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: 'component' }, limit, offset: 10, orderFields, + totalItems: 'exclude', }); expect(result.current.offset).toEqual(10); }); @@ -1061,6 +1088,9 @@ describe(``, () => { }); expect(result.current.backendEntities.length).toBe(2); + // The count effect fires first (consuming one rejection), then the + // list call fires. Both must reject for the error to surface. + mockCatalogApi.queryEntities!.mockRejectedValueOnce('error'); mockCatalogApi.queryEntities!.mockRejectedValueOnce('error'); act(() => { result.current.updateFilters({ diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index db7b48822a..7feb079aae 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -35,6 +35,7 @@ import { 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'; @@ -263,11 +264,11 @@ export const EntityListProvider = ( const response = await catalogApi.queryEntities({ cursor, limit, + totalItems: 'exclude', }); return { backendEntities: response.items, pageInfo: response.pageInfo, - totalItems: response.totalItems, }; }; } else { @@ -278,11 +279,11 @@ export const EntityListProvider = ( ...backendFilter, limit, offset, + totalItems: 'exclude', }); return { backendEntities: response.items, pageInfo: response.pageInfo, - totalItems: response.totalItems, }; }; } @@ -345,6 +346,32 @@ export const EntityListProvider = ( // several filters will be calling updateFilters in rapid succession. useDebounce(refresh, 10, [adjustedFilters, cursor, limit, offset]); + // Fetch the total count separately, only when filters change. This is + // decoupled from the main list fetch so that page navigation doesn't + // re-run the expensive count query, and so that the count can arrive + // asynchronously without blocking the list response. + const [{ value: asyncTotalItems }, refreshCount] = useAsyncFn(async () => { + if (paginationMode === 'none') { + return undefined; + } + const compacted = compact(Object.values(adjustedFilters)); + if (compacted.length === 0) { + return undefined; + } + const backendFilter = reduceCatalogFilters(compacted); + try { + const response = await catalogApi.queryEntities({ + ...backendFilter, + limit: 0, + }); + return response.totalItems; + } catch { + return undefined; + } + }, [catalogApi, paginationMode, adjustedFilters]); + + useDebounce(refreshCount, 10, [adjustedFilters]); + // Frontend filtering — synchronous, no debounce needed. Updates // instantly when requestedFilters or backendEntities change. const entities = useMemo(() => { @@ -446,7 +473,9 @@ export const EntityListProvider = ( error, pageInfo, totalItems: - paginationMode === 'none' ? entities.length : backendState.totalItems, + paginationMode === 'none' + ? entities.length + : asyncTotalItems ?? backendState.totalItems, limit, offset, setLimit, @@ -457,6 +486,7 @@ export const EntityListProvider = ( requestedFilters, entities, backendState, + asyncTotalItems, updateFilters, queryParameters, loading, From 9772b28bd86c634de9719f7fbf2a26f69f999666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 21 May 2026 18:04:40 +0200 Subject: [PATCH 02/12] Restore dedup test and add split-count coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the 'does not re-fetch when backend filter params are unchanged' test from #34324 that was accidentally dropped during the rebase. Add a new test verifying that cursor navigation does not re-run the count query (the core split-count behavior). Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/hooks/useEntityListProvider.test.tsx | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 02d5ef3960..a1f358d739 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -299,6 +299,46 @@ 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); + }); + + 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 }), @@ -648,6 +688,47 @@ describe('', () => { }); }); + it('fetches count separately and does not re-count on cursor navigation', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBe(2); + }); + + // The count query uses limit: 0 (without totalItems: 'exclude') + await waitFor(() => { + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ limit: 0 }), + ); + }); + + const countCallsBefore = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.limit === 0).length; + + // Navigate to next page via cursor — should NOT re-run the count + act(() => { + result.current.pageInfo?.next?.(); + }); + + await waitFor(() => { + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + cursor: expect.any(String), + totalItems: 'exclude', + }), + ); + }); + + const countCallsAfter = ( + mockCatalogApi.queryEntities as jest.Mock + ).mock.calls.filter((c: any) => c[0]?.limit === 0).length; + + expect(countCallsAfter).toBe(countCallsBefore); + }); + it('returns an error on catalogApi failure', async () => { const { result } = renderHook(() => useEntityList(), { wrapper: createWrapper({ pagination }), From 0e1c7e9504c9440839956fa03019dddc8f5ba807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 10:57:03 +0200 Subject: [PATCH 03/12] Expose totalItemsLoading and improve table loading UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add totalItemsLoading to EntityListContextProps so consumers can distinguish a stale count (being refreshed) from a fresh one. Rename the internal asyncTotalItems to totalItems. Update CatalogTable to show a dimmed count while the count is loading, and only show the full-table spinner on the very first load (when no entities exist yet). Subsequent fetches keep stale rows visible with a small spinner next to the title. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/report-alpha.api.md | 20 ++++---- plugins/catalog-react/report.api.md | 15 +++--- plugins/catalog-react/src/deprecated.tsx | 1 + .../src/hooks/useEntityListProvider.test.tsx | 1 + .../src/hooks/useEntityListProvider.tsx | 46 ++++++++++--------- .../MockEntityListContextProvider.tsx | 1 + .../CatalogTable/CatalogTable.test.tsx | 3 +- .../components/CatalogTable/CatalogTable.tsx | 41 +++++++++-------- 8 files changed, 70 insertions(+), 58 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 5634b56df4..7f45bf81e9 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -81,20 +81,20 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; - readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; + readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more'; + readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; - readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel'; readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity'; @@ -120,13 +120,13 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.label': 'Label'; readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.tags': 'Tags'; - readonly 'entityTableColumnTitle.domain': 'Domain'; - readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; + readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.targets': 'Targets'; + readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'entityRelationCard.emptyHelpLinkTitle': 'Learn how to change this.'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; @@ -666,16 +666,16 @@ export const EntityTableColumnTitle: ( input: EntityTableColumnTitleProps, ) => | 'System' - | 'Title' | 'Domain' + | 'Name' + | 'Description' | 'Lifecycle' | 'Namespace' | 'Owner' | 'Tags' | 'Type' - | 'Name' - | 'Description' | 'Targets' + | 'Title' | 'Label'; // @alpha (undocumented) diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index 1fcac904ff..6614ec729e 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -203,20 +203,20 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; - readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; + readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more'; + readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; - readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel'; readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity'; @@ -242,13 +242,13 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.label': 'Label'; readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.tags': 'Tags'; - readonly 'entityTableColumnTitle.domain': 'Domain'; - readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; + readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.targets': 'Targets'; + readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'entityRelationCard.emptyHelpLinkTitle': 'Learn how to change this.'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; @@ -466,6 +466,7 @@ export type EntityListContextProps< prev?: () => void; }; totalItems?: number; + totalItemsLoading: boolean; limit: number; offset?: number; setLimit: (limit: number) => void; diff --git a/plugins/catalog-react/src/deprecated.tsx b/plugins/catalog-react/src/deprecated.tsx index 7d347dda6b..dcb176dff4 100644 --- a/plugins/catalog-react/src/deprecated.tsx +++ b/plugins/catalog-react/src/deprecated.tsx @@ -73,6 +73,7 @@ export function MockEntityListContextProvider< error: value?.error, totalItems: value?.totalItems ?? (value?.entities ?? defaultValues.entities).length, + totalItemsLoading: value?.totalItemsLoading ?? false, limit: value?.limit ?? 20, offset: value?.offset, setLimit: value?.setLimit ?? (() => {}), diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index a1f358d739..e0abfccfbd 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -1239,6 +1239,7 @@ describe('versioned context', () => { updateFilters: jest.fn(), queryParameters: {}, loading: true, + totalItemsLoading: false, limit: 277, setLimit: jest.fn(), setOffset: jest.fn(), diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 7feb079aae..e42e391ee9 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -121,6 +121,7 @@ export type EntityListContextProps< prev?: () => void; }; totalItems?: number; + totalItemsLoading: boolean; limit: number; offset?: number; setLimit: (limit: number) => void; @@ -350,25 +351,26 @@ export const EntityListProvider = ( // decoupled from the main list fetch so that page navigation doesn't // re-run the expensive count query, and so that the count can arrive // asynchronously without blocking the list response. - const [{ value: asyncTotalItems }, refreshCount] = useAsyncFn(async () => { - if (paginationMode === 'none') { - return undefined; - } - const compacted = compact(Object.values(adjustedFilters)); - if (compacted.length === 0) { - return undefined; - } - const backendFilter = reduceCatalogFilters(compacted); - try { - const response = await catalogApi.queryEntities({ - ...backendFilter, - limit: 0, - }); - return response.totalItems; - } catch { - return undefined; - } - }, [catalogApi, paginationMode, adjustedFilters]); + const [{ value: totalItems, loading: totalItemsLoading }, refreshCount] = + useAsyncFn(async () => { + if (paginationMode === 'none') { + return undefined; + } + const compacted = compact(Object.values(adjustedFilters)); + if (compacted.length === 0) { + return undefined; + } + const backendFilter = reduceCatalogFilters(compacted); + try { + const response = await catalogApi.queryEntities({ + ...backendFilter, + limit: 0, + }); + return response.totalItems; + } catch { + return undefined; + } + }, [catalogApi, paginationMode, adjustedFilters]); useDebounce(refreshCount, 10, [adjustedFilters]); @@ -475,7 +477,8 @@ export const EntityListProvider = ( totalItems: paginationMode === 'none' ? entities.length - : asyncTotalItems ?? backendState.totalItems, + : totalItems ?? backendState.totalItems, + totalItemsLoading: paginationMode !== 'none' && totalItemsLoading, limit, offset, setLimit, @@ -486,7 +489,8 @@ export const EntityListProvider = ( requestedFilters, entities, backendState, - asyncTotalItems, + totalItems, + totalItemsLoading, updateFilters, queryParameters, loading, diff --git a/plugins/catalog-react/src/testUtils/MockEntityListContextProvider.tsx b/plugins/catalog-react/src/testUtils/MockEntityListContextProvider.tsx index e75e7e20e3..c13821b815 100644 --- a/plugins/catalog-react/src/testUtils/MockEntityListContextProvider.tsx +++ b/plugins/catalog-react/src/testUtils/MockEntityListContextProvider.tsx @@ -73,6 +73,7 @@ export function MockEntityListContextProvider< error: value?.error, totalItems: value?.totalItems ?? (value?.entities ?? defaultValues.entities).length, + totalItemsLoading: value?.totalItemsLoading ?? false, limit: value?.limit ?? 20, offset: value?.offset, setLimit: value?.setLimit ?? (() => {}), diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 0d81753653..76a6c35b35 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -126,7 +126,8 @@ describe('CatalogTable component', () => { }, }, ); - expect(screen.getByText(/Owned Components \(3\)/)).toBeInTheDocument(); + expect(screen.getByText(/Owned Components/)).toBeInTheDocument(); + expect(screen.getByText(/\(3\)/)).toBeInTheDocument(); expect(screen.getByText(/component1/)).toBeInTheDocument(); expect(screen.getByText(/component2/)).toBeInTheDocument(); expect(screen.getByText(/component3/)).toBeInTheDocument(); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index b021aae0e1..e76f4ef27a 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -110,16 +110,15 @@ export const CatalogTable = (props: CatalogTableProps) => { filters, pageInfo, totalItems, + totalItemsLoading, paginationMode, } = entityListContext; - // For non-paginated tables, only show the full loading indicator when - // there's no data yet (initial load). During filter changes we keep stale - // data visible and let the new results swap in seamlessly. For paginated - // tables we always show loading, since stale data from a different page - // would be misleading. - const isLoading = - paginationMode === 'none' ? loading && entities.length === 0 : loading; + // Only show the full-table loading spinner on the very first load when + // there's no data yet. During subsequent fetches (filter changes, page + // navigation) we keep stale rows visible so the user sees content + // immediately — a small spinner next to the title signals the refresh. + const isLoading = loading && entities.length === 0; const tableColumns = useMemo( () => @@ -202,22 +201,26 @@ export const CatalogTable = (props: CatalogTableProps) => { const titlePreamble = capitalize( filters.user?.value ?? t('catalogTable.allFilters'), ); - const titleText = + const titleBase = props.title || - [titlePreamble, currentType, pluralize(currentKind), currentCount] + [titlePreamble, currentType, pluralize(currentKind)] .filter(s => s) .join(' '); - const title = - loading && !isLoading ? ( - - {titleText} + const title = ( + + {titleBase} + {currentCount && ( + + {currentCount} + + )} + {loading && !isLoading && ( - - ) : ( - titleText - ); + )} + + ); const actions = props.actions || defaultActions; const options: TableProps['options'] = { From 3a425b827eed22448b404d649bb4d9f28ed781f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 11:56:31 +0200 Subject: [PATCH 04/12] Regenerate API reports for catalog-react 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/report-alpha.api.md | 22 +++++++++++----------- plugins/catalog-react/report.api.md | 16 ++++++++-------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 7f45bf81e9..5634b56df4 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -81,20 +81,20 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; - readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; + readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; + readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more'; - readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; - readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel'; readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity'; @@ -120,13 +120,13 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.label': 'Label'; readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; - readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; - readonly 'entityTableColumnTitle.owner': 'Owner'; - readonly 'entityTableColumnTitle.targets': 'Targets'; readonly 'entityTableColumnTitle.tags': 'Tags'; + readonly 'entityTableColumnTitle.domain': 'Domain'; + readonly 'entityTableColumnTitle.owner': 'Owner'; + readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; + readonly 'entityTableColumnTitle.targets': 'Targets'; readonly 'entityRelationCard.emptyHelpLinkTitle': 'Learn how to change this.'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; @@ -666,16 +666,16 @@ export const EntityTableColumnTitle: ( input: EntityTableColumnTitleProps, ) => | 'System' + | 'Title' | 'Domain' - | 'Name' - | 'Description' | 'Lifecycle' | 'Namespace' | 'Owner' | 'Tags' | 'Type' + | 'Name' + | 'Description' | 'Targets' - | 'Title' | 'Label'; // @alpha (undocumented) diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index 6614ec729e..72bc2a0194 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -203,20 +203,20 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; - readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; + readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; + readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more'; - readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; - readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel'; readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity'; @@ -242,13 +242,13 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.label': 'Label'; readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; - readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; - readonly 'entityTableColumnTitle.owner': 'Owner'; - readonly 'entityTableColumnTitle.targets': 'Targets'; readonly 'entityTableColumnTitle.tags': 'Tags'; + readonly 'entityTableColumnTitle.domain': 'Domain'; + readonly 'entityTableColumnTitle.owner': 'Owner'; + readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; + readonly 'entityTableColumnTitle.targets': 'Targets'; readonly 'entityRelationCard.emptyHelpLinkTitle': 'Learn how to change this.'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; From 291b3df57915368e2394e1ca0033e727499c93ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:00:53 +0200 Subject: [PATCH 05/12] Update changeset to cover catalog table loading UX changes 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/catalog-react-split-count.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/catalog-react-split-count.md b/.changeset/catalog-react-split-count.md index c48c806c7b..3067665333 100644 --- a/.changeset/catalog-react-split-count.md +++ b/.changeset/catalog-react-split-count.md @@ -1,5 +1,8 @@ --- '@backstage/plugin-catalog-react': patch +'@backstage/plugin-catalog': patch --- -The entity list provider now fetches the entity list and the total count as two separate parallel requests when using cursor or offset pagination. The list query skips the expensive count computation (using `totalItems: 'exclude'`), so the table populates immediately. The count arrives asynchronously and updates the title. This significantly improves perceived latency for catalog page loads on large catalogs. +The entity list provider now fetches the entity list and the total count as two separate parallel requests when using cursor or offset pagination. The list query skips the expensive count computation (using `totalItems: 'exclude'`), so the table populates immediately. The count arrives asynchronously and updates the title. A new `totalItemsLoading` field is exposed on `EntityListContextProps` so consumers can distinguish a stale count from a fresh one. + +The catalog table now keeps stale rows visible during filter changes and page navigation instead of replacing the entire table body with a spinner. The full-table spinner is only shown on the very first load when no data exists yet. The entity count in the title is dimmed while the count is refreshing, and a small spinner appears next to the title while rows are loading. From 9d013340a1a4b3a7e60d8ce636fe0115a9ec0470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:21:26 +0200 Subject: [PATCH 06/12] Derive defaultKind from displayed entities, not filter state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When stale rows are kept visible during a kind filter change, the name column's defaultKind should match the entities being displayed, not the pending filter. Otherwise entity names flash with a kind prefix (e.g. "api:foo") until new data arrives. The column layout switch still uses the filter's kind so the table shape updates immediately. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../CatalogTable/defaultCatalogTableColumnsFunc.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/catalog/src/components/CatalogTable/defaultCatalogTableColumnsFunc.tsx b/plugins/catalog/src/components/CatalogTable/defaultCatalogTableColumnsFunc.tsx index 54dd701770..ac4a8b1b69 100644 --- a/plugins/catalog/src/components/CatalogTable/defaultCatalogTableColumnsFunc.tsx +++ b/plugins/catalog/src/components/CatalogTable/defaultCatalogTableColumnsFunc.tsx @@ -25,11 +25,18 @@ export const defaultCatalogTableColumnsFunc: CatalogTableColumnsFunc = ({ filters, entities, }) => { + // Derive the name column's defaultKind from the displayed entities so + // that the entity ref prefix stays consistent with the rows during + // filter transitions (when stale rows are kept visible while new data + // loads). The column layout switch still uses the filter's kind so the + // table shape updates immediately. + const defaultKind = + entities[0]?.kind?.toLocaleLowerCase('en-US') ?? filters.kind?.value; const showTypeColumn = filters.type === undefined; return [ columnFactories.createTitleColumn({ hidden: true }), - columnFactories.createNameColumn({ defaultKind: filters.kind?.value }), + columnFactories.createNameColumn({ defaultKind }), ...createEntitySpecificColumns(), ]; From 588514536a591538eb4ef920d819e0fd279334e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:28:12 +0200 Subject: [PATCH 07/12] Keep title consistent with displayed data during transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the title's kind label from the displayed entities instead of the filter state. Only show the count when both the list and count have settled (not loading), so stale counts from a previous filter don't appear alongside new data. The spinner shows whenever anything is still loading (list OR count). Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../components/CatalogTable/CatalogTable.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index e76f4ef27a..0b2a3db877 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -194,29 +194,37 @@ export const CatalogTable = (props: CatalogTableProps) => { }, ]; - const currentKind = filters.kind?.label || ''; + // Derive the title's kind label from the displayed entities so the + // title stays consistent with the rows during filter transitions. + const displayedKind = + entities[0]?.kind?.toLocaleLowerCase('en-US') ?? filters.kind?.value; + const displayedKindLabel = + displayedKind === filters.kind?.value?.toLocaleLowerCase('en-US') + ? filters.kind?.label || '' + : capitalize(displayedKind || ''); const currentType = filters.type?.value || ''; - const currentCount = typeof totalItems === 'number' ? `(${totalItems})` : ''; + const countIsCorrect = + typeof totalItems === 'number' && !totalItemsLoading && !loading; + const currentCount = countIsCorrect ? `(${totalItems})` : ''; + const somethingIsLoading = loading || totalItemsLoading; // TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar const titlePreamble = capitalize( filters.user?.value ?? t('catalogTable.allFilters'), ); const titleBase = props.title || - [titlePreamble, currentType, pluralize(currentKind)] + [titlePreamble, currentType, pluralize(displayedKindLabel)] .filter(s => s) .join(' '); - const title = ( + const title = props.title ? ( + titleBase + ) : ( {titleBase} - {currentCount && ( - - {currentCount} - - )} - {loading && !isLoading && ( + {currentCount} + {somethingIsLoading && !isLoading && ( )} From 8fe727430b3215651b235534c69ed5e81069bc90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:29:31 +0200 Subject: [PATCH 08/12] Add space before count parenthesis in table title 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/src/components/CatalogTable/CatalogTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 0b2a3db877..3ee808b8c8 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -205,7 +205,7 @@ export const CatalogTable = (props: CatalogTableProps) => { const currentType = filters.type?.value || ''; const countIsCorrect = typeof totalItems === 'number' && !totalItemsLoading && !loading; - const currentCount = countIsCorrect ? `(${totalItems})` : ''; + const currentCount = countIsCorrect ? ` (${totalItems})` : ''; const somethingIsLoading = loading || totalItemsLoading; // TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar const titlePreamble = capitalize( From 87727f50db1893a34a6ff97ff71c7af176b8e3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:31:29 +0200 Subject: [PATCH 09/12] Derive column layout from displayed entities, not filter state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column set (System, Owner, Type, etc.) now stays consistent with the displayed rows during kind filter transitions, same as the name column's defaultKind. The switch statement uses the entity-derived kind so columns don't jump until new data actually arrives. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../components/CatalogTable/CatalogTable.test.tsx | 6 +++++- .../defaultCatalogTableColumnsFunc.tsx | 15 +++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 76a6c35b35..df796ff321 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -295,11 +295,15 @@ describe('CatalogTable component', () => { ])( 'should render correct columns with kind filter $kind', async ({ kind, expectedColumns }) => { + const kindEntities = entities.map(e => ({ + ...e, + kind: kind ?? e.kind, + })); await renderInTestApp( { - // Derive the name column's defaultKind from the displayed entities so - // that the entity ref prefix stays consistent with the rows during - // filter transitions (when stale rows are kept visible while new data - // loads). The column layout switch still uses the filter's kind so the - // table shape updates immediately. - const defaultKind = + // Derive the effective kind from the displayed entities so that both + // the column layout and the name column's defaultKind stay consistent + // with the rows during filter transitions (when stale rows are kept + // visible while new data loads). + const effectiveKind = entities[0]?.kind?.toLocaleLowerCase('en-US') ?? filters.kind?.value; const showTypeColumn = filters.type === undefined; return [ columnFactories.createTitleColumn({ hidden: true }), - columnFactories.createNameColumn({ defaultKind }), + columnFactories.createNameColumn({ defaultKind: effectiveKind }), ...createEntitySpecificColumns(), ]; @@ -51,7 +50,7 @@ export const defaultCatalogTableColumnsFunc: CatalogTableColumnsFunc = ({ columnFactories.createSpecTypeColumn({ hidden: !showTypeColumn }), columnFactories.createSpecLifecycleColumn(), ]; - switch (filters.kind?.value) { + switch (effectiveKind) { case 'user': return [...descriptionTagColumns]; case 'domain': From 11c225cb68a340c8f492db74f2c9326d8b3fbe0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:35:42 +0200 Subject: [PATCH 10/12] Preserve kind casing in table title during transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the filter's label (which has proper casing like "AiResources") when it matches the displayed entities. Fall back to the entity's kind field directly instead of lowercasing and re-capitalizing, which mangled camelCase kinds like AiResource into Airesource. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/components/CatalogTable/CatalogTable.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 3ee808b8c8..af49c0b48c 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -196,12 +196,14 @@ export const CatalogTable = (props: CatalogTableProps) => { // Derive the title's kind label from the displayed entities so the // title stays consistent with the rows during filter transitions. - const displayedKind = - entities[0]?.kind?.toLocaleLowerCase('en-US') ?? filters.kind?.value; + // Use the filter's label when it matches (it has proper casing), + // otherwise fall back to the entity's kind field directly. + const displayedKind = entities[0]?.kind ?? filters.kind?.value; const displayedKindLabel = - displayedKind === filters.kind?.value?.toLocaleLowerCase('en-US') + displayedKind?.toLocaleLowerCase('en-US') === + filters.kind?.value?.toLocaleLowerCase('en-US') ? filters.kind?.label || '' - : capitalize(displayedKind || ''); + : displayedKind || ''; const currentType = filters.type?.value || ''; const countIsCorrect = typeof totalItems === 'number' && !totalItemsLoading && !loading; From 470b1ce4d588049d203dc1cd3c841028cc3f6c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:37:25 +0200 Subject: [PATCH 11/12] Keep old count visible until new rows arrive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the previous count alongside the spinner during loading. Only hide the count when new rows have arrived but the count hasn't caught up yet (would be wrong for the new data). This makes the only immediate visual change after a filter switch the spinner appearing. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../catalog/src/components/CatalogTable/CatalogTable.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index af49c0b48c..801b1ffd17 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -205,9 +205,12 @@ export const CatalogTable = (props: CatalogTableProps) => { ? filters.kind?.label || '' : displayedKind || ''; const currentType = filters.type?.value || ''; - const countIsCorrect = - typeof totalItems === 'number' && !totalItemsLoading && !loading; - const currentCount = countIsCorrect ? ` (${totalItems})` : ''; + // Show the count as long as we have one. Hide it only when new rows + // have arrived but the count hasn't caught up yet — at that point + // the old count would be wrong for the new data. + const countIsStale = !loading && totalItemsLoading; + const currentCount = + typeof totalItems === 'number' && !countIsStale ? ` (${totalItems})` : ''; const somethingIsLoading = loading || totalItemsLoading; // TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar const titlePreamble = capitalize( From e2994512d6557505117d8f723d991c5659df08a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 22 May 2026 12:41:19 +0200 Subject: [PATCH 12/12] Only show full-table spinner on truly initial load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track whether the table has ever received data. A filter change that empties the client-side entity list (e.g. filtering by an owner with no matches) should not trigger the full-table loading spinner — the table should show "No records" or stale rows with the title spinner, not flash a large spinner. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/components/CatalogTable/CatalogTable.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 801b1ffd17..cd70d2e466 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -43,7 +43,7 @@ import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; import { capitalize, sortBy } from 'lodash'; import pluralize from 'pluralize'; -import { ReactNode, useMemo } from 'react'; +import { ReactNode, useMemo, useRef } from 'react'; import { columnFactories } from './columns'; import { CatalogTableColumnsFunc, CatalogTableRow } from './types'; import { OffsetPaginatedCatalogTable } from './OffsetPaginatedCatalogTable'; @@ -114,11 +114,14 @@ export const CatalogTable = (props: CatalogTableProps) => { paginationMode, } = entityListContext; - // Only show the full-table loading spinner on the very first load when - // there's no data yet. During subsequent fetches (filter changes, page - // navigation) we keep stale rows visible so the user sees content - // immediately — a small spinner next to the title signals the refresh. - const isLoading = loading && entities.length === 0; + // Track whether we've ever received data. The full-table spinner should + // only show on the truly initial load — not when a filter change + // empties the client-side entity list before the backend responds. + const hasHadData = useRef(false); + if (entities.length > 0) { + hasHadData.current = true; + } + const isLoading = loading && !hasHadData.current; const tableColumns = useMemo( () =>