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

View File

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

View File

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