catalog-react: split entity list and count into parallel requests
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) <noreply@anthropic.com> Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -299,48 +299,6 @@ describe('<EntityListProvider />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-fetch when backend filter params are unchanged', async () => {
|
||||
const deferred = createDeferred<GetEntitiesResponse>();
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
});
|
||||
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entities.length).toBe(2);
|
||||
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
act(() =>
|
||||
@@ -631,8 +607,17 @@ describe('<EntityListProvider pagination />', () => {
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
});
|
||||
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
});
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
|
||||
cursor: 'nextCursor',
|
||||
limit,
|
||||
totalItems: 'exclude',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -750,6 +737,7 @@ describe('<EntityListProvider pagination />', () => {
|
||||
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
|
||||
cursor: 'prevCursor',
|
||||
limit,
|
||||
totalItems: 'exclude',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -836,12 +824,12 @@ describe(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
});
|
||||
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entities.length).toBe(2);
|
||||
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
act(() =>
|
||||
@@ -957,21 +963,40 @@ describe(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
});
|
||||
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
});
|
||||
|
||||
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(`<EntityListProvider pagination={{ mode: 'offset' }} />`, () => {
|
||||
});
|
||||
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({
|
||||
|
||||
@@ -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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
...backendFilter,
|
||||
limit,
|
||||
offset,
|
||||
totalItems: 'exclude',
|
||||
});
|
||||
return {
|
||||
backendEntities: response.items,
|
||||
pageInfo: response.pageInfo,
|
||||
totalItems: response.totalItems,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -345,6 +346,32 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
|
||||
// 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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
requestedFilters,
|
||||
entities,
|
||||
backendState,
|
||||
asyncTotalItems,
|
||||
updateFilters,
|
||||
queryParameters,
|
||||
loading,
|
||||
|
||||
Reference in New Issue
Block a user