DocDoeAI / src /hooks /useJobPolling.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
3.47 kB
"use client";
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { getPublicPollIntervalMs } from "@/lib/env";
type UseJobPollingOptions<T> = {
enabled: boolean;
fetcher: () => Promise<T>;
shouldContinue: (value: T) => boolean;
onData: (value: T) => void;
onError?: (error: unknown) => void;
onTimeout?: () => void;
intervalMs?: number;
timeoutMs?: number;
};
export function useJobPolling<T>({
enabled,
fetcher,
shouldContinue,
onData,
onError,
onTimeout,
intervalMs = getPublicPollIntervalMs(),
timeoutMs = 10 * 60 * 1000,
}: UseJobPollingOptions<T>) {
const [isPolling, setIsPolling] = useState(false);
const startedAtRef = useRef<number | null>(null);
const requestActiveRef = useRef(false);
const stoppedRef = useRef(false);
const timedOutRef = useRef(false);
const consecutiveErrorsRef = useRef(0);
const generationRef = useRef(0);
// A render runs for minutes; a single failed poll (HF cold-start, a network
// blip, a brief 5xx) must NOT abort the whole job in the UI while the backend
// keeps rendering fine. Tolerate transient errors and only give up after
// several in a row. A successful poll resets the counter.
const MAX_CONSECUTIVE_ERRORS = 6;
const pollOnce = useEffectEvent(async () => {
if (requestActiveRef.current) return;
const generation = generationRef.current;
requestActiveRef.current = true;
try {
const data = await fetcher();
if (generation !== generationRef.current || stoppedRef.current) return;
consecutiveErrorsRef.current = 0;
onData(data);
if (!shouldContinue(data)) {
stoppedRef.current = true;
setIsPolling(false);
}
} catch (error) {
if (generation !== generationRef.current || stoppedRef.current) return;
consecutiveErrorsRef.current += 1;
if (consecutiveErrorsRef.current >= MAX_CONSECUTIVE_ERRORS) {
stoppedRef.current = true;
setIsPolling(false);
onError?.(error);
}
// otherwise: swallow and let the next interval retry
} finally {
requestActiveRef.current = false;
}
});
const markTimedOutIfNeeded = useEffectEvent(() => {
const startedAt = startedAtRef.current ?? Date.now();
if (Date.now() - startedAt <= timeoutMs) return false;
stoppedRef.current = true;
setIsPolling(false);
if (!timedOutRef.current) {
timedOutRef.current = true;
onTimeout?.();
}
return true;
});
useEffect(() => {
if (!enabled) {
stoppedRef.current = true;
const timeoutId = window.setTimeout(() => setIsPolling(false), 0);
return () => window.clearTimeout(timeoutId);
}
stoppedRef.current = false;
timedOutRef.current = false;
consecutiveErrorsRef.current = 0;
startedAtRef.current = Date.now();
const startTimeoutId = window.setTimeout(() => setIsPolling(true), 0);
const initialPollTimeoutId = window.setTimeout(() => void pollOnce(), 0);
const intervalId = window.setInterval(() => {
if (stoppedRef.current) return;
if (markTimedOutIfNeeded()) return;
setIsPolling(true);
void pollOnce();
}, intervalMs);
return () => {
generationRef.current += 1;
stoppedRef.current = true;
window.clearTimeout(startTimeoutId);
window.clearTimeout(initialPollTimeoutId);
window.clearInterval(intervalId);
};
}, [enabled, intervalMs]);
return { isPolling };
}