/** * useJobPolling.ts — recursive-setTimeout polling for a backend job. * * Why setTimeout and not setInterval? * ------------------------------------ * setInterval fires at a fixed wall-clock cadence regardless of whether the * previous request has finished. setTimeout-based recursion only schedules the * NEXT poll after the CURRENT one resolves, so there is never more than one * in-flight request at a time. * * steps[] persistence * ------------------- * The steps array accumulates across poll cycles — startPolling() only resets * pollPhase and errorMessage, not steps/currentStep. This lets the history * navigator keep showing all known steps while a new step is being processed. * * refreshResult() * --------------- * A lightweight one-shot GET that updates steps/currentStep in state without * touching pollPhase. Used after goToStep() to sync local state without * triggering the phase-transition effects that startPolling() would cause. */ import { useCallback, useEffect, useRef, useState } from "react"; import { getJobResult } from "../api/client"; import type { StepSummary } from "../api/client"; const POLL_INTERVAL_MS = 2000; export type PollPhase = "idle" | "polling" | "completed" | "failed"; interface PollState { pollPhase: PollPhase; errorMessage: string | null; steps: StepSummary[]; currentStep: number; } const INITIAL_STATE: PollState = { pollPhase: "idle", errorMessage: null, steps: [], currentStep: 0, }; type JobResultFetcher = typeof getJobResult; export function useJobPolling(fetcher: JobResultFetcher = getJobResult) { const [state, setState] = useState(INITIAL_STATE); const timeoutRef = useRef | null>(null); const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; if (timeoutRef.current !== null) clearTimeout(timeoutRef.current); }; }, []); const stopPolling = useCallback(() => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }, []); /** * Begin polling for the given jobId. * Resets pollPhase/errorMessage but preserves steps[] so history stays visible * while the new step is processing. */ const startPolling = useCallback((jobId: string) => { stopPolling(); if (!isMountedRef.current) return; // Only reset phase/error — keep steps/currentStep from prior polls. setState(prev => ({ ...prev, pollPhase: "polling", errorMessage: null })); const poll = async () => { try { const result = await fetcher(jobId); if (!isMountedRef.current) return; if (result.status === "completed") { setState({ pollPhase: "completed", errorMessage: null, steps: result.steps, currentStep: result.current_step, }); } else if (result.status === "failed") { setState(prev => ({ ...prev, pollPhase: "failed", errorMessage: result.error_message ?? "Job failed with no error details.", steps: result.steps, currentStep: result.current_step, })); } else { // "pending" or "processing" — keep polling timeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS); } } catch (err) { if (!isMountedRef.current) return; setState(prev => ({ ...prev, pollPhase: "failed", errorMessage: `Network error while polling: ${(err as Error).message}`, })); } }; poll(); }, [stopPolling, fetcher]); /** * Silently refresh steps[] and currentStep from the backend without touching * pollPhase. Call this after goToStep() to sync local state. */ const refreshResult = useCallback(async (jobId: string) => { try { const result = await fetcher(jobId); if (!isMountedRef.current) return; setState(prev => ({ ...prev, steps: result.steps, currentStep: result.current_step, })); } catch { /* ignore — stale state is acceptable here */ } }, [fetcher]); /** Stop polling and reset ALL state back to idle, including steps[]. */ const reset = useCallback(() => { stopPolling(); if (isMountedRef.current) setState(INITIAL_STATE); }, [stopPolling]); /** * Reset pollPhase to "idle" WITHOUT clearing steps[] or currentStep. * Use this when the user initiates "Do More Work" — we want the history * strip to stay visible but the pollPhase→phase sync effect must stop * fighting against the manually-set "painting" phase. */ const resetPollPhase = useCallback(() => { stopPolling(); if (isMountedRef.current) { setState(prev => ({ ...prev, pollPhase: "idle", errorMessage: null })); } }, [stopPolling]); return { pollPhase: state.pollPhase, errorMessage: state.errorMessage, steps: state.steps, currentStep: state.currentStep, startPolling, refreshResult, stopPolling, reset, resetPollPhase, }; }