- 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.
81 lines
2.5 KiB
TypeScript
81 lines
2.5 KiB
TypeScript
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
|
import styles from './InfiniteScrollArea.module.css';
|
|
|
|
export interface InfiniteScrollAreaProps {
|
|
onEndReached: () => void;
|
|
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). */
|
|
scrollRef?: RefObject<HTMLDivElement | null>;
|
|
className?: string;
|
|
}
|
|
|
|
export function InfiniteScrollArea({
|
|
onEndReached,
|
|
onTopVisibilityChange,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
isLoading = false,
|
|
hasItems = true,
|
|
maxHeight = 360,
|
|
children,
|
|
scrollRef,
|
|
className,
|
|
}: InfiniteScrollAreaProps) {
|
|
const internalRef = useRef<HTMLDivElement | null>(null);
|
|
const containerRef = scrollRef ?? internalRef;
|
|
const topSentinel = useRef<HTMLDivElement | null>(null);
|
|
const bottomSentinel = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!onTopVisibilityChange) return;
|
|
const root = containerRef.current;
|
|
const target = topSentinel.current;
|
|
if (!root || !target) return;
|
|
const obs = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const e of entries) onTopVisibilityChange(e.isIntersecting);
|
|
},
|
|
{ root, threshold: 1.0 },
|
|
);
|
|
obs.observe(target);
|
|
return () => obs.disconnect();
|
|
}, [containerRef, onTopVisibilityChange]);
|
|
|
|
useEffect(() => {
|
|
if (!hasNextPage) return;
|
|
const root = containerRef.current;
|
|
const target = bottomSentinel.current;
|
|
if (!root || !target) return;
|
|
const obs = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const e of entries) if (e.isIntersecting) onEndReached();
|
|
},
|
|
{ root, rootMargin: '100px', threshold: 0 },
|
|
);
|
|
obs.observe(target);
|
|
return () => obs.disconnect();
|
|
}, [containerRef, onEndReached, hasNextPage]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`${styles.scrollArea}${className ? ` ${className}` : ''}`}
|
|
style={{ maxHeight }}
|
|
>
|
|
<div ref={topSentinel} className={styles.sentinel} aria-hidden="true" />
|
|
{children}
|
|
<div ref={bottomSentinel} className={styles.sentinel} aria-hidden="true" />
|
|
{isFetchingNextPage && <div className={styles.loadingMore}>Loading more…</div>}
|
|
{!hasNextPage && !isLoading && hasItems && (
|
|
<div className={styles.endOfStream}>End of stream</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|