fix(catalog-react): eliminate redundant fetches in useEntityListProvider

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) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-05-20 17:29:44 +02:00
parent dfb920fb6e
commit 4222c73fd3
@@ -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<any> | undefined
>(undefined);
type OutputState<EntityFilters extends DefaultEntityFilters> = {
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 = <EntityFilters extends DefaultEntityFilters>(
const [offset, setOffset] = useState<number | undefined>(initialOffset);
const [limit, setLimit] = useState(initialLimit);
const [outputState, setOutputState] = useState<OutputState<EntityFilters>>(
() => {
return {
appliedFilters: {} as EntityFilters,
entities: [],
backendEntities: [],
pageInfo: {},
offset,
limit,
};
},
);
const [backendState, setBackendState] = useState<BackendState>({
backendEntities: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>();
// 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<unknown>(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<BackendState>;
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<string, string | string[]>,
);
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<string, string | string[]>);
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 = <EntityFilters extends DefaultEntityFilters>(
location.search,
offset,
requestedFilters,
resolvedValue,
paginationMode,
]);
@@ -454,36 +414,31 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
[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 = <EntityFilters extends DefaultEntityFilters>(
paginationMode,
}),
[
latestOutput,
requestedFilters,
entities,
backendState,
updateFilters,
queryParameters,
loading,
error,
pageInfo,
paginationMode,
limit,
offset,
paginationMode,
setLimit,
setOffset,
],