fix(ui): stabilize infinite-stream callbacks + suppress empty-state flash

- useInfiniteStream: wrap fetchNextPage and refresh in useCallback so
  InfiniteScrollArea's IntersectionObserver does not re-subscribe on
  every parent render.
- InfiniteScrollArea: do not render 'End of stream' until at least one
  item has loaded and the initial query has settled (was flashing on
  mount before first fetch).
- AgentHealth: pass isLoading + hasItems to both InfiniteScrollArea
  wrappers.
This commit is contained in:
hsiegeln
2026-04-17 12:52:39 +02:00
parent fb7d6db375
commit 7f233460aa
3 changed files with 22 additions and 6 deletions

View File

@@ -6,6 +6,8 @@ export interface InfiniteScrollAreaProps {
onTopVisibilityChange?: (atTop: boolean) => void;
isFetchingNextPage: boolean;
hasNextPage: boolean;
isLoading?: boolean;
hasItems?: boolean;
maxHeight?: number | string;
children: ReactNode;
/** Optional caller-owned scroll container ref (e.g. for scroll-to-top on refresh). */
@@ -18,6 +20,8 @@ export function InfiniteScrollArea({
onTopVisibilityChange,
isFetchingNextPage,
hasNextPage,
isLoading = false,
hasItems = true,
maxHeight = 360,
children,
scrollRef,
@@ -68,7 +72,9 @@ export function InfiniteScrollArea({
{children}
<div ref={bottomSentinel} className={styles.sentinel} aria-hidden="true" />
{isFetchingNextPage && <div className={styles.loadingMore}>Loading more</div>}
{!hasNextPage && <div className={styles.endOfStream}>End of stream</div>}
{!hasNextPage && !isLoading && hasItems && (
<div className={styles.endOfStream}>End of stream</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
export interface StreamPage<T> {
data: T[];
@@ -52,14 +52,20 @@ export function useInfiniteStream<T>(args: UseInfiniteStreamArgs<T>): UseInfinit
[query.data],
);
const fetchNextPage = useCallback(() => {
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: [...queryKey] });
}, [queryClient, queryKey]);
return {
items,
fetchNextPage: () => {
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
},
fetchNextPage,
hasNextPage: !!query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading,
refresh: () => queryClient.invalidateQueries({ queryKey: [...queryKey] }),
refresh,
};
}

View File

@@ -957,6 +957,8 @@ export default function AgentHealth() {
onTopVisibilityChange={setIsLogAtTop}
isFetchingNextPage={logStream.isFetchingNextPage}
hasNextPage={logStream.hasNextPage}
isLoading={logStream.isLoading}
hasItems={logStream.items.length > 0}
maxHeight={360}
>
{filteredLogs.length > 0 ? (
@@ -993,6 +995,8 @@ export default function AgentHealth() {
onTopVisibilityChange={setIsTimelineAtTop}
isFetchingNextPage={eventStream.isFetchingNextPage}
hasNextPage={eventStream.hasNextPage}
isLoading={eventStream.isLoading}
hasItems={eventStream.items.length > 0}
maxHeight={360}
>
{feedEvents.length > 0 ? (