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