diff --git a/src/hooks/useUploadQueue.ts b/src/hooks/useUploadQueue.ts index 42aaf6f..55a3ec6 100644 --- a/src/hooks/useUploadQueue.ts +++ b/src/hooks/useUploadQueue.ts @@ -1,4 +1,4 @@ -import { useCallback, useReducer } from 'react'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; export type UploadStatus = 'pending' | 'uploading' | 'success' | 'error'; @@ -30,12 +30,18 @@ function reducer(state: UploadFile[], action: Action): UploadFile[] { })), ]; case 'SET_STATUS': + if (!state.some((f) => f.id === action.id)) { + return state; + } return state.map((f) => f.id === action.id ? { ...f, status: action.status, errorMessage: action.errorMessage } : f, ); case 'SET_PROGRESS': + if (!state.some((f) => f.id === action.id)) { + return state; + } return state.map((f) => f.id === action.id ? { ...f, progress: Math.max(0, Math.min(100, action.progress)) } : f, ); @@ -66,10 +72,32 @@ export interface UseUploadQueueReturn { export type Uploader = ( file: File, onProgress: (pct: number) => void, + signal?: AbortSignal, ) => Promise; export function useUploadQueue(): UseUploadQueueReturn { const [queue, dispatch] = useReducer(reducer, []); + const inFlightRef = useRef>(new Set()); + const controllersRef = useRef>(new Map()); + const queueRef = useRef([]); + + useEffect(() => { + queueRef.current = queue; + }, [queue]); + + useEffect(() => { + return () => { + controllersRef.current.forEach((controller) => { + try { + controller.abort(); + } catch { + /* ignore */ + } + }); + controllersRef.current.clear(); + inFlightRef.current.clear(); + }; + }, []); const addFiles = useCallback((files: File[]) => { if (files.length === 0) return; @@ -77,22 +105,68 @@ export function useUploadQueue(): UseUploadQueueReturn { }, []); const removeFile = useCallback((id: string) => { + const controller = controllersRef.current.get(id); + if (controller) { + try { + controller.abort(); + } catch { + /* ignore */ + } + controllersRef.current.delete(id); + } + inFlightRef.current.delete(id); dispatch({ type: 'REMOVE', id }); }, []); const runUpload = useCallback( async (item: UploadFile, uploader: Uploader, dispatchFn: React.Dispatch) => { + if (inFlightRef.current.has(item.id)) { + return; + } + const controller = new AbortController(); + controllersRef.current.set(item.id, controller); + inFlightRef.current.add(item.id); dispatchFn({ type: 'SET_STATUS', id: item.id, status: 'uploading' }); dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: 0 }); try { - await uploader(item.file, (pct) => { - dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: pct }); - }); + await uploader( + item.file, + (pct) => { + if (controller.signal.aborted) { + return; + } + dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: pct }); + }, + controller.signal, + ); + if (controller.signal.aborted) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } + if (!queueRef.current.some((f) => f.id === item.id)) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: 100 }); dispatchFn({ type: 'SET_STATUS', id: item.id, status: 'success' }); } catch (err) { + if (controller.signal.aborted) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } + if (!queueRef.current.some((f) => f.id === item.id)) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } const msg = err instanceof Error ? err.message : 'Upload failed'; dispatchFn({ type: 'SET_STATUS', id: item.id, status: 'error', errorMessage: msg }); + } finally { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); } }, [], @@ -100,19 +174,26 @@ export function useUploadQueue(): UseUploadQueueReturn { const uploadFiles = useCallback( (uploader: Uploader) => { - const pending = queue.filter((f) => f.status === 'pending'); - pending.forEach((item) => runUpload(item, uploader, dispatch)); + const pending = queueRef.current.filter( + (f) => f.status === 'pending' && !inFlightRef.current.has(f.id), + ); + pending.forEach((item) => { + runUpload(item, uploader, dispatch); + }); }, - [queue, runUpload], + [runUpload], ); const retryFile = useCallback( (id: string, uploader: Uploader) => { - const item = queue.find((f) => f.id === id); + if (inFlightRef.current.has(id)) { + return; + } + const item = queueRef.current.find((f) => f.id === id); if (!item) return; runUpload({ ...item, status: 'pending', progress: 0 }, uploader, dispatch); }, - [queue, runUpload], + [runUpload], ); const clearComplete = useCallback(() => {