[Fix] Address review feedback on paginated daily activity hook

1. Replace ...args spread in useEffect deps with JSON.stringify(args) key
   to prevent infinite re-renders when callers pass unstable array references.
2. Add missing agentCancelled partial-data message in EntityUsage so the
   outer condition no longer renders an empty div.
3. Store setTimeout ID in a ref and clearTimeout on cleanup/cancel to avoid
   orphaned timers under rapid re-renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang
2026-03-14 09:55:57 -07:00
co-authored by Claude Opus 4.6
parent 0cd4a68157
commit db37f31099
2 changed files with 37 additions and 6 deletions
@@ -416,6 +416,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
<button onClick={agentCancel} className="text-blue-600 hover:text-blue-800 underline text-xs">Stop</button>
</>
)}
{agentCancelled && entityType === "team" && (
<span className="text-yellow-600 text-xs">
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
</span>
)}
</div>
)}
<UsageExportHeader
@@ -102,11 +102,24 @@ export function usePaginatedDailyActivity({
const fetchIdRef = useRef(0);
const cancelledRef = useRef(false);
const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep args in a ref so the effect can always read the latest values
// without needing them in the dependency array.
const argsRef = useRef(args);
argsRef.current = args;
// Stable serialised key so the effect only re-runs when the arg *values* change.
const argsKey = JSON.stringify(args);
const cancel = useCallback(() => {
cancelledRef.current = true;
setCancelled(true);
setIsFetchingMore(false);
if (delayTimerRef.current !== null) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
}, []);
useEffect(() => {
@@ -126,14 +139,24 @@ export function usePaginatedDailyActivity({
const isStale = () =>
fetchIdRef.current !== currentFetchId || cancelledRef.current;
/** Cancellable delay that clears itself on cleanup. */
const delay = (ms: number) =>
new Promise<void>((resolve) => {
delayTimerRef.current = setTimeout(() => {
delayTimerRef.current = null;
resolve();
}, ms);
});
const run = async () => {
const currentArgs = argsRef.current;
setLoading(true);
setIsFetchingMore(false);
setProgress({ currentPage: 1, totalPages: 1 });
try {
// Inject page=1 as the 4th argument.
const argsWithPage = [...args.slice(0, 3), 1, ...args.slice(3)];
const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)];
const firstPage = await fetchFn(...argsWithPage);
if (isStale()) return;
@@ -160,13 +183,11 @@ export function usePaginatedDailyActivity({
if (isStale()) return;
// Small delay to avoid overwhelming the backend.
await new Promise((resolve) =>
setTimeout(resolve, PAGE_FETCH_DELAY_MS),
);
await delay(PAGE_FETCH_DELAY_MS);
if (isStale()) return;
const argsForPage = [...args.slice(0, 3), page, ...args.slice(3)];
const argsForPage = [...currentArgs.slice(0, 3), page, ...currentArgs.slice(3)];
const pageData = await fetchFn(...argsForPage);
if (isStale()) return;
@@ -201,9 +222,14 @@ export function usePaginatedDailyActivity({
return () => {
fetchIdRef.current++;
if (delayTimerRef.current !== null) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
};
// argsKey is a stable JSON string so the effect only re-fires when arg values change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, fetchFn, ...args]);
}, [enabled, fetchFn, argsKey]);
return { data, loading, isFetchingMore, progress, cancelled, cancel };
}