image-tool / frontend /src /hooks /useJobPolling.ts
JunyoungK7's picture
Claude Sonnet 4.6
Deploy to HF Spaces: Docker setup, CPU inference, env-gated config
a3953cb
Raw History Blame Contribute Delete
5.24 kB
/**
* 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<PollState>(INITIAL_STATE);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}