File size: 3,472 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | "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 };
}
|