feat(ui): add useInfiniteStream hook
Wraps tanstack useInfiniteQuery with cursor flattening, top-gated polling, and a refresh() invalidator. Used by log and agent-event streaming views.
This commit is contained in:
65
ui/src/hooks/useInfiniteStream.ts
Normal file
65
ui/src/hooks/useInfiniteStream.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export interface StreamPage<T> {
|
||||||
|
data: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseInfiniteStreamArgs<T> {
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
fetchPage: (cursor: string | undefined) => Promise<StreamPage<T>>;
|
||||||
|
enabled?: boolean;
|
||||||
|
/** When true, the query auto-refetches every refetchMs ms. When false, polling pauses. */
|
||||||
|
isAtTop: boolean;
|
||||||
|
refetchMs?: number;
|
||||||
|
staleTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseInfiniteStreamResult<T> {
|
||||||
|
items: T[];
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over tanstack useInfiniteQuery that:
|
||||||
|
* - flattens pages into a single items[] array (newest first)
|
||||||
|
* - gates auto-refetch on isAtTop (so a user scrolled down does not lose their viewport)
|
||||||
|
* - exposes refresh() that invalidates the query (reset to page 1 on next render)
|
||||||
|
*/
|
||||||
|
export function useInfiniteStream<T>(args: UseInfiniteStreamArgs<T>): UseInfiniteStreamResult<T> {
|
||||||
|
const { queryKey, fetchPage, enabled = true, isAtTop, refetchMs = 15_000, staleTime = 300 } = args;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useInfiniteQuery<StreamPage<T>, Error>({
|
||||||
|
queryKey: [...queryKey],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: ({ pageParam }) => fetchPage(pageParam as string | undefined),
|
||||||
|
getNextPageParam: (last) => (last.hasMore && last.nextCursor ? last.nextCursor : undefined),
|
||||||
|
enabled,
|
||||||
|
refetchInterval: isAtTop ? refetchMs : false,
|
||||||
|
staleTime,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = useMemo<T[]>(
|
||||||
|
() => (query.data?.pages ?? []).flatMap((p) => p.data),
|
||||||
|
[query.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
fetchNextPage: () => {
|
||||||
|
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
|
||||||
|
},
|
||||||
|
hasNextPage: !!query.hasNextPage,
|
||||||
|
isFetchingNextPage: query.isFetchingNextPage,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
refresh: () => queryClient.invalidateQueries({ queryKey: [...queryKey] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user