Pause flow list updates while scrolled

This commit is contained in:
dirtydishes 2025-12-30 18:02:08 -05:00
parent ae54f3ad0c
commit 9908c431f0

View file

@ -276,6 +276,7 @@ type ListScrollState = {
isAtTop: boolean; isAtTop: boolean;
isAtTopRef: React.MutableRefObject<boolean>; isAtTopRef: React.MutableRefObject<boolean>;
missed: number; missed: number;
resumeTick: number;
onNewItems: (count: number) => void; onNewItems: (count: number) => void;
jumpToTop: () => void; jumpToTop: () => void;
}; };
@ -284,7 +285,9 @@ const useListScroll = (): ListScrollState => {
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const [isAtTop, setIsAtTop] = useState(true); const [isAtTop, setIsAtTop] = useState(true);
const [missed, setMissed] = useState(0); const [missed, setMissed] = useState(0);
const [resumeTick, setResumeTick] = useState(0);
const isAtTopRef = useRef(true); const isAtTopRef = useRef(true);
const prevAtTopRef = useRef(true);
useEffect(() => { useEffect(() => {
isAtTopRef.current = isAtTop; isAtTopRef.current = isAtTop;
@ -298,6 +301,11 @@ const useListScroll = (): ListScrollState => {
const atTop = el.scrollTop <= 2; const atTop = el.scrollTop <= 2;
if (atTop && !prevAtTopRef.current) {
setResumeTick((prev) => prev + 1);
}
prevAtTopRef.current = atTop;
isAtTopRef.current = atTop; isAtTopRef.current = atTop;
setIsAtTop(atTop); setIsAtTop(atTop);
@ -353,6 +361,7 @@ const useListScroll = (): ListScrollState => {
isAtTop, isAtTop,
isAtTopRef, isAtTopRef,
missed, missed,
resumeTick,
onNewItems, onNewItems,
jumpToTop jumpToTop
}; };
@ -793,6 +802,8 @@ const useLiveStream = <T extends SortableItem>(
expectedType: MessageType; expectedType: MessageType;
onNewItems?: (count: number) => void; onNewItems?: (count: number) => void;
captureScroll?: () => void; captureScroll?: () => void;
shouldHold?: () => boolean;
resumeSignal?: number;
} }
): TapeState<T> => { ): TapeState<T> => {
const [status, setStatus] = useState<WsStatus>( const [status, setStatus] = useState<WsStatus>(
@ -810,6 +821,7 @@ const useLiveStream = <T extends SortableItem>(
const pendingRef = useRef<T[]>([]); const pendingRef = useRef<T[]>([]);
const pendingCountRef = useRef(0); const pendingCountRef = useRef(0);
const flushHandleRef = useRef<number | null>(null); const flushHandleRef = useRef<number | null>(null);
const holdRef = useRef<T[]>([]);
useEffect(() => { useEffect(() => {
pausedRef.current = paused; pausedRef.current = paused;
@ -842,14 +854,25 @@ const useLiveStream = <T extends SortableItem>(
config.onNewItems(pendingCount); config.onNewItems(pendingCount);
} }
if (config.captureScroll) { const shouldHold = config.shouldHold ? config.shouldHold() : false;
if (!shouldHold && config.captureScroll) {
config.captureScroll(); config.captureScroll();
} }
setItems((prev) => mergeNewest(buffered, prev)); if (shouldHold) {
holdRef.current = mergeNewest(buffered, holdRef.current);
setLastUpdate(Date.now());
return;
}
const nextBatch =
holdRef.current.length > 0 ? [...holdRef.current, ...buffered] : buffered;
holdRef.current = [];
setItems((prev) => mergeNewest(nextBatch, prev));
setLastUpdate(Date.now()); setLastUpdate(Date.now());
}); });
}, [config.captureScroll, config.onNewItems]); }, [config.captureScroll, config.onNewItems, config.shouldHold]);
const togglePause = useCallback(() => { const togglePause = useCallback(() => {
setPaused((prev) => { setPaused((prev) => {
@ -868,6 +891,7 @@ const useLiveStream = <T extends SortableItem>(
setLastUpdate(null); setLastUpdate(null);
pendingRef.current = []; pendingRef.current = [];
pendingCountRef.current = 0; pendingCountRef.current = 0;
holdRef.current = [];
cancelFlush(); cancelFlush();
return; return;
} }
@ -951,6 +975,21 @@ const useLiveStream = <T extends SortableItem>(
}; };
}, [config.enabled, config.expectedType, config.wsPath, scheduleFlush, cancelFlush]); }, [config.enabled, config.expectedType, config.wsPath, scheduleFlush, cancelFlush]);
useEffect(() => {
if (config.resumeSignal === undefined) {
return;
}
if (config.shouldHold && config.shouldHold()) {
return;
}
if (holdRef.current.length === 0) {
return;
}
setItems((prev) => mergeNewest(holdRef.current, prev));
holdRef.current = [];
setLastUpdate(Date.now());
}, [config.resumeSignal, config.shouldHold]);
return { return {
status, status,
items, items,
@ -966,14 +1005,18 @@ const useLiveStream = <T extends SortableItem>(
const useFlowStream = ( const useFlowStream = (
enabled: boolean, enabled: boolean,
onNewItems?: (count: number) => void, onNewItems?: (count: number) => void,
captureScroll?: () => void captureScroll?: () => void,
shouldHold?: () => boolean,
resumeSignal?: number
): TapeState<FlowPacket> => { ): TapeState<FlowPacket> => {
return useLiveStream<FlowPacket>({ return useLiveStream<FlowPacket>({
enabled, enabled,
wsPath: "/ws/flow", wsPath: "/ws/flow",
expectedType: "flow-packet", expectedType: "flow-packet",
onNewItems, onNewItems,
captureScroll captureScroll,
shouldHold,
resumeSignal
}); });
}; };
@ -1311,7 +1354,14 @@ export default function HomePage() {
pollMs: mode === "replay" ? 200 : undefined pollMs: mode === "replay" ? 200 : undefined
}); });
const flow = useFlowStream(mode === "live", flowScroll.onNewItems, flowAnchor.capture); const flowHold = useCallback(() => !flowScroll.isAtTopRef.current, [flowScroll.isAtTopRef]);
const flow = useFlowStream(
mode === "live",
flowScroll.onNewItems,
flowAnchor.capture,
flowHold,
flowScroll.resumeTick
);
const alerts = useLiveStream<AlertEvent>({ const alerts = useLiveStream<AlertEvent>({
enabled: mode === "live", enabled: mode === "live",
wsPath: "/ws/alerts", wsPath: "/ws/alerts",