diff --git a/apps/api/src/routes/progress.ts b/apps/api/src/routes/progress.ts index bd191367..cd4f4e71 100644 --- a/apps/api/src/routes/progress.ts +++ b/apps/api/src/routes/progress.ts @@ -194,7 +194,7 @@ export function updateSingleFileProgress(progress: Omit singleFileCompletions.delete(progress.jobId), 120_000); + setTimeout(() => singleFileCompletions.delete(progress.jobId), 600_000); } const subs = listeners.get(progress.jobId); diff --git a/apps/web/src/hooks/use-connection-monitor.ts b/apps/web/src/hooks/use-connection-monitor.ts index b4fb0177..b890956b 100644 --- a/apps/web/src/hooks/use-connection-monitor.ts +++ b/apps/web/src/hooks/use-connection-monitor.ts @@ -14,8 +14,15 @@ export function useConnectionMonitor() { } }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + store.getState().checkHealth(); + } + }; + window.addEventListener("offline", handleOffline); window.addEventListener("online", handleOnline); + document.addEventListener("visibilitychange", handleVisibilityChange); store.getState().checkHealth(); @@ -48,6 +55,7 @@ export function useConnectionMonitor() { return () => { window.removeEventListener("offline", handleOffline); window.removeEventListener("online", handleOnline); + document.removeEventListener("visibilitychange", handleVisibilityChange); store.getState().stopPolling(); unsubscribe(); }; diff --git a/apps/web/src/hooks/use-pipeline-processor.ts b/apps/web/src/hooks/use-pipeline-processor.ts index 20ba2711..4d30cc59 100644 --- a/apps/web/src/hooks/use-pipeline-processor.ts +++ b/apps/web/src/hooks/use-pipeline-processor.ts @@ -37,10 +37,71 @@ export function usePipelineProcessor() { const xhrRef = useRef(null); const eventSourceRef = useRef(null); const abortRef = useRef(null); + const activeJobIdRef = useRef(null); - // Clean up on unmount useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (!activeJobIdRef.current) return; + if (eventSourceRef.current && eventSourceRef.current.readyState === EventSource.OPEN) { + return; + } + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + const jobId = activeJobIdRef.current; + setTimeout(() => { + if (!activeJobIdRef.current || activeJobIdRef.current !== jobId) return; + try { + const es = new EventSource(`/api/v1/jobs/${jobId}/progress`); + eventSourceRef.current = es; + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === "single" && typeof data.percent === "number") { + const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT); + setProgress((prev) => ({ + ...prev, + phase: "processing", + percent: Math.max(prev.percent, scaled), + stage: data.stage, + })); + } + if (data.type === "batch") { + const pct = + data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15; + setProgress((prev) => ({ + ...prev, + phase: "processing", + percent: pct, + stage: data.currentFile + ? `Processing ${data.currentFile} (${data.completedFiles}/${data.totalFiles})` + : `Processing ${data.completedFiles}/${data.totalFiles}`, + })); + } + } catch { + // Ignore malformed SSE + } + }; + + es.onerror = () => { + es.close(); + eventSourceRef.current = null; + }; + } catch { + // EventSource creation failed + } + }, 500); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); if (elapsedRef.current) clearInterval(elapsedRef.current); if (eventSourceRef.current) eventSourceRef.current.close(); if (xhrRef.current) xhrRef.current.abort(); @@ -72,6 +133,7 @@ export function usePipelineProcessor() { }, 1000); const clientJobId = generateId(); + activeJobIdRef.current = clientJobId; // Open SSE for real-time progress from the server try { @@ -178,6 +240,7 @@ export function usePipelineProcessor() { setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; }; xhr.onerror = () => { @@ -189,6 +252,7 @@ export function usePipelineProcessor() { setError("Network error - check your connection"); setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; }; xhr.ontimeout = () => { @@ -200,6 +264,7 @@ export function usePipelineProcessor() { setError("Request timed out - the server may be overloaded. Try again."); setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; }; xhr.open("POST", "/api/v1/pipeline/execute"); @@ -234,6 +299,7 @@ export function usePipelineProcessor() { }, 1000); const clientJobId = generateId(); + activeJobIdRef.current = clientJobId; // Open SSE before upload for real-time progress try { @@ -355,6 +421,7 @@ export function usePipelineProcessor() { setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; } catch (err) { if (elapsedRef.current) clearInterval(elapsedRef.current); if (eventSourceRef.current) { @@ -364,6 +431,7 @@ export function usePipelineProcessor() { setError(err instanceof Error ? err.message : "Batch processing failed"); setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; } }, [processSingle, setProcessing, setError], diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index 4a573bad..59224ff9 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -47,20 +47,133 @@ export function useToolProcessor(toolId: string) { const eventSourceRef = useRef(null); const abortRef = useRef(null); const stallTimerRef = useRef | null>(null); + const activeJobIdRef = useRef(null); + const asyncModeRef = useRef(false); const isAiTool = AI_PYTHON_TOOLS.has(toolId); const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId; - // Clean up on unmount + const reconnectSSE = useCallback(() => { + const jobId = activeJobIdRef.current; + if (!jobId) return; + if (eventSourceRef.current && eventSourceRef.current.readyState === EventSource.OPEN) { + return; + } + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + try { + const es = new EventSource(`/api/v1/jobs/${jobId}/progress`); + eventSourceRef.current = es; + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type !== "single") return; + + if (asyncModeRef.current && stallTimerRef.current) { + clearTimeout(stallTimerRef.current); + stallTimerRef.current = setTimeout(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (elapsedRef.current) clearInterval(elapsedRef.current); + activeJobIdRef.current = null; + setError( + "Processing timed out with no progress for 5 minutes. Try again or use a smaller image.", + ); + setProcessing(false); + setProgress(IDLE_PROGRESS); + }, SSE_STALL_TIMEOUT_MS); + } + + if (data.phase === "complete" && data.result) { + if (stallTimerRef.current) clearTimeout(stallTimerRef.current); + if (elapsedRef.current) clearInterval(elapsedRef.current); + es.close(); + eventSourceRef.current = null; + activeJobIdRef.current = null; + + const result = data.result as ProcessResult; + setWarning(result.warning ?? null); + const idx = useFileStore.getState().selectedIndex; + useFileStore.getState().updateEntry(idx, { + processedUrl: result.downloadUrl, + processedPreviewUrl: result.previewUrl ?? null, + processedFilename: null, + status: "completed", + originalSize: result.originalSize, + processedSize: result.processedSize, + ...(result.savedFileId ? { serverFileId: result.savedFileId } : {}), + }); + setProcessing(false); + setProgress(IDLE_PROGRESS); + return; + } + + if (data.phase === "failed") { + if (stallTimerRef.current) clearTimeout(stallTimerRef.current); + if (elapsedRef.current) clearInterval(elapsedRef.current); + es.close(); + eventSourceRef.current = null; + activeJobIdRef.current = null; + setError(data.error || "Processing failed"); + setProcessing(false); + setProgress(IDLE_PROGRESS); + return; + } + + if (typeof data.percent === "number") { + const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT); + setProgress((prev) => ({ + ...prev, + phase: "processing", + percent: Math.max(prev.percent, scaled), + stage: data.stage, + })); + } + } catch { + // Ignore malformed SSE + } + }; + + es.onerror = () => { + if (!asyncModeRef.current) { + es.close(); + eventSourceRef.current = null; + } + }; + } catch { + // EventSource creation failed + } + }, [setError, setProcessing]); + + // Reconnect SSE when tab becomes visible again (mobile tab recovery) useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (!activeJobIdRef.current) return; + if (eventSourceRef.current && eventSourceRef.current.readyState === EventSource.OPEN) { + return; + } + setTimeout(() => reconnectSSE(), 500); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); if (elapsedRef.current) clearInterval(elapsedRef.current); if (eventSourceRef.current) eventSourceRef.current.close(); if (xhrRef.current) xhrRef.current.abort(); if (abortRef.current) abortRef.current.abort(); if (stallTimerRef.current) clearTimeout(stallTimerRef.current); }; - }, []); + }, [reconnectSSE]); const processFiles = useCallback( (files: File[], settings: Record) => { @@ -92,6 +205,8 @@ export function useToolProcessor(toolId: string) { }, 1000); const clientJobId = generateId(); + activeJobIdRef.current = clientJobId; + asyncModeRef.current = false; let asyncMode = false; const clearStallTimer = () => { @@ -139,6 +254,7 @@ export function useToolProcessor(toolId: string) { if (elapsedRef.current) clearInterval(elapsedRef.current); es.close(); eventSourceRef.current = null; + activeJobIdRef.current = null; const result = data.result as ProcessResult; setWarning(result.warning ?? null); @@ -161,6 +277,7 @@ export function useToolProcessor(toolId: string) { if (elapsedRef.current) clearInterval(elapsedRef.current); es.close(); eventSourceRef.current = null; + activeJobIdRef.current = null; setError(data.error || "Processing failed"); setProcessing(false); setProgress(IDLE_PROGRESS); @@ -236,6 +353,7 @@ export function useToolProcessor(toolId: string) { xhr.onload = () => { if (xhr.status === 202) { asyncMode = true; + asyncModeRef.current = true; resetStallTimer(); return; } @@ -280,6 +398,7 @@ export function useToolProcessor(toolId: string) { setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; }; xhr.onerror = () => { @@ -292,6 +411,7 @@ export function useToolProcessor(toolId: string) { setError("Processing was interrupted. Retry when reconnected."); setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; }; xhr.ontimeout = () => { @@ -304,6 +424,7 @@ export function useToolProcessor(toolId: string) { setError("Request timed out - the server may be overloaded. Try again."); setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; }; xhr.open("POST", `/api/v1/tools/${toolId}`); @@ -338,6 +459,7 @@ export function useToolProcessor(toolId: string) { }, 1000); const clientJobId = generateId(); + activeJobIdRef.current = clientJobId; // Open SSE before upload for real-time progress try { @@ -450,6 +572,7 @@ export function useToolProcessor(toolId: string) { setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; } catch (err) { if (elapsedRef.current) clearInterval(elapsedRef.current); if (eventSourceRef.current) { @@ -459,6 +582,7 @@ export function useToolProcessor(toolId: string) { setError(err instanceof Error ? err.message : "Batch processing failed"); setProcessing(false); setProgress(IDLE_PROGRESS); + activeJobIdRef.current = null; } }, [toolId, processFiles, setProcessing, setError, toolName], diff --git a/apps/web/src/stores/features-store.ts b/apps/web/src/stores/features-store.ts index 2ef4eadf..05998c6b 100644 --- a/apps/web/src/stores/features-store.ts +++ b/apps/web/src/stores/features-store.ts @@ -147,6 +147,26 @@ export const useFeaturesStore = create((set, get) => { } }; + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible") return; + const activeIds = Object.keys(get().installing); + if (activeIds.length === 0) return; + + for (const bundleId of activeIds) { + const es = esRefs[bundleId]; + if (es && es.readyState === EventSource.OPEN) continue; + if (es) { + es.close(); + delete esRefs[bundleId]; + } + if (!pollRefs[bundleId]) { + startPolling(bundleId); + } + } + }); + } + return { bundles: [], loaded: false, diff --git a/tests/unit/web/visibility-recovery.test.ts b/tests/unit/web/visibility-recovery.test.ts new file mode 100644 index 00000000..43439c25 --- /dev/null +++ b/tests/unit/web/visibility-recovery.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + get length() { + return 0; + }, + key: vi.fn(() => null), +}); + +function okHealth() { + return Promise.resolve(new Response(JSON.stringify({ status: "healthy" }), { status: 200 })); +} + +function simulateVisible() { + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +function simulateHidden() { + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +describe("visibility recovery", () => { + beforeEach(() => { + vi.useFakeTimers(); + fetchMock.mockReset(); + fetchMock.mockImplementation(() => okHealth()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("connection monitor", () => { + it("calls checkHealth when tab becomes visible", async () => { + const { useConnectionStore } = await import("@/stores/connection-store"); + const { useConnectionMonitor } = await import("@/hooks/use-connection-monitor"); + const { renderHook } = await import("@testing-library/react"); + + useConnectionStore.setState({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + }); + fetchMock.mockReset(); + fetchMock.mockImplementation(() => okHealth()); + + const { unmount } = renderHook(() => useConnectionMonitor()); + + await vi.advanceTimersByTimeAsync(0); + const initialCalls = fetchMock.mock.calls.length; + + simulateVisible(); + await vi.advanceTimersByTimeAsync(0); + + expect(fetchMock.mock.calls.length).toBeGreaterThan(initialCalls); + unmount(); + }); + + it("does not call checkHealth when tab becomes hidden", async () => { + const { useConnectionStore } = await import("@/stores/connection-store"); + const { useConnectionMonitor } = await import("@/hooks/use-connection-monitor"); + const { renderHook } = await import("@testing-library/react"); + + useConnectionStore.setState({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + }); + fetchMock.mockReset(); + fetchMock.mockImplementation(() => okHealth()); + + const { unmount } = renderHook(() => useConnectionMonitor()); + await vi.advanceTimersByTimeAsync(0); + const callsAfterMount = fetchMock.mock.calls.length; + + simulateHidden(); + await vi.advanceTimersByTimeAsync(0); + + expect(fetchMock.mock.calls.length).toBe(callsAfterMount); + unmount(); + }); + }); +});