"use client"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import { getPublicPollIntervalMs } from "@/lib/env"; type UseJobPollingOptions = { enabled: boolean; fetcher: () => Promise; shouldContinue: (value: T) => boolean; onData: (value: T) => void; onError?: (error: unknown) => void; onTimeout?: () => void; intervalMs?: number; timeoutMs?: number; }; export function useJobPolling({ enabled, fetcher, shouldContinue, onData, onError, onTimeout, intervalMs = getPublicPollIntervalMs(), timeoutMs = 10 * 60 * 1000, }: UseJobPollingOptions) { const [isPolling, setIsPolling] = useState(false); const startedAtRef = useRef(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 }; }