From 7495604ad3b76e77c46c5d8b06f1c389ecfc62e4 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sun, 22 Mar 2026 14:55:45 +0800 Subject: [PATCH] fix(auth-files): prevent batch status race conditions --- .../authFiles/hooks/useAuthFilesData.ts | 120 ++++++++++++------ src/pages/AuthFilesPage.tsx | 14 +- 2 files changed, 90 insertions(+), 44 deletions(-) diff --git a/src/features/authFiles/hooks/useAuthFilesData.ts b/src/features/authFiles/hooks/useAuthFilesData.ts index 571397c1..0a9cc731 100644 --- a/src/features/authFiles/hooks/useAuthFilesData.ts +++ b/src/features/authFiles/hooks/useAuthFilesData.ts @@ -30,6 +30,7 @@ export type UseAuthFilesDataResult = { deleting: string | null; deletingAll: boolean; statusUpdating: Record; + batchStatusUpdating: boolean; fileInputRef: RefObject; loadFiles: () => Promise; handleUploadClick: () => void; @@ -63,9 +64,11 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles const [deleting, setDeleting] = useState(null); const [deletingAll, setDeletingAll] = useState(false); const [statusUpdating, setStatusUpdating] = useState>({}); + const [batchStatusUpdating, setBatchStatusUpdating] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const fileInputRef = useRef(null); + const batchStatusPendingRef = useRef(false); const selectionCount = selectedFiles.size; const toggleSelect = useCallback((name: string) => { setSelectedFiles((prev) => { @@ -438,62 +441,94 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles const batchSetStatus = useCallback( async (names: string[], enabled: boolean) => { + if (batchStatusPendingRef.current) return; + const uniqueNames = Array.from(new Set(names)); if (uniqueNames.length === 0) return; + if (uniqueNames.some((name) => statusUpdating[name] === true)) return; + + const originalDisabled = new Map( + files + .filter((file) => uniqueNames.includes(file.name)) + .map((file) => [file.name, file.disabled === true]) + ); + const targetNames = new Set(originalDisabled.keys()); + const targetNameList = Array.from(targetNames); + if (targetNameList.length === 0) return; - const targetNames = new Set(uniqueNames); const nextDisabled = !enabled; + batchStatusPendingRef.current = true; + setBatchStatusUpdating(true); + setStatusUpdating((prev) => { + const next = { ...prev }; + targetNameList.forEach((name) => { + next[name] = true; + }); + return next; + }); setFiles((prev) => prev.map((file) => targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file ) ); - const results = await Promise.allSettled( - uniqueNames.map((name) => authFilesApi.setStatus(name, nextDisabled)) - ); - - let successCount = 0; - let failCount = 0; - const failedNames = new Set(); - const confirmedDisabled = new Map(); - - results.forEach((result, index) => { - const name = uniqueNames[index]; - if (result.status === 'fulfilled') { - successCount++; - confirmedDisabled.set(name, result.value.disabled); - } else { - failCount++; - failedNames.add(name); - } - }); - - setFiles((prev) => - prev.map((file) => { - if (failedNames.has(file.name)) { - return { ...file, disabled: !nextDisabled }; - } - if (confirmedDisabled.has(file.name)) { - return { ...file, disabled: confirmedDisabled.get(file.name) }; - } - return file; - }) - ); - - if (failCount === 0) { - showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success'); - } else { - showNotification( - t('auth_files.batch_status_partial', { success: successCount, failed: failCount }), - 'warning' + try { + const results = await Promise.allSettled( + targetNameList.map((name) => authFilesApi.setStatus(name, nextDisabled)) ); - } - deselectAll(); + let successCount = 0; + let failCount = 0; + const failedNames = new Set(); + const confirmedDisabled = new Map(); + + results.forEach((result, index) => { + const name = targetNameList[index]; + if (result.status === 'fulfilled') { + successCount++; + confirmedDisabled.set(name, result.value.disabled); + } else { + failCount++; + failedNames.add(name); + } + }); + + setFiles((prev) => + prev.map((file) => { + if (failedNames.has(file.name)) { + return { ...file, disabled: originalDisabled.get(file.name) === true }; + } + if (confirmedDisabled.has(file.name)) { + return { ...file, disabled: confirmedDisabled.get(file.name) }; + } + return file; + }) + ); + + if (failCount === 0) { + showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success'); + } else { + showNotification( + t('auth_files.batch_status_partial', { success: successCount, failed: failCount }), + 'warning' + ); + } + + deselectAll(); + } finally { + batchStatusPendingRef.current = false; + setBatchStatusUpdating(false); + setStatusUpdating((prev) => { + const next = { ...prev }; + targetNameList.forEach((name) => { + delete next[name]; + }); + return next; + }); + } }, - [deselectAll, showNotification, t] + [deselectAll, files, showNotification, statusUpdating, t] ); const batchDownload = useCallback( @@ -609,6 +644,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles deleting, deletingAll, statusUpdating, + batchStatusUpdating, fileInputRef, loadFiles, handleUploadClick, diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 15943db4..205d2c82 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -99,6 +99,7 @@ export function AuthFilesPage() { deleting, deletingAll, statusUpdating, + batchStatusUpdating, fileInputRef, loadFiles, handleUploadClick, @@ -356,6 +357,15 @@ export function AuthFilesPage() { [sorted] ); const selectedNames = useMemo(() => Array.from(selectedFiles), [selectedFiles]); + const selectedHasStatusUpdating = useMemo( + () => selectedNames.some((name) => statusUpdating[name] === true), + [selectedNames, statusUpdating] + ); + const batchStatusButtonsDisabled = + disableControls || + selectedNames.length === 0 || + batchStatusUpdating || + selectedHasStatusUpdating; const copyTextWithNotification = useCallback( async (text: string) => { @@ -887,7 +897,7 @@ export function AuthFilesPage() { @@ -895,7 +905,7 @@ export function AuthFilesPage() { variant="secondary" size="sm" onClick={() => batchSetStatus(selectedNames, false)} - disabled={disableControls || selectedNames.length === 0} + disabled={batchStatusButtonsDisabled} > {t('auth_files.batch_disable')}