From 6649168d70109800eb67277c1b79c2e623d1da8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E7=99=BB=E5=B1=B1?= Date: Mon, 27 Apr 2026 09:08:47 +0800 Subject: [PATCH] fix: prevent duplicate dialog delete submissions --- components/dialog-host.tsx | 127 +++++++++++++++++--------------- lib/feedback/dialog-action.js | 27 +++++++ lib/feedback/dialog-action.ts | 45 +++++++++++ tests/lib/dialog-action.test.ts | 75 +++++++++++++++++++ 4 files changed, 216 insertions(+), 58 deletions(-) create mode 100644 lib/feedback/dialog-action.js create mode 100644 lib/feedback/dialog-action.ts create mode 100644 tests/lib/dialog-action.test.ts diff --git a/components/dialog-host.tsx b/components/dialog-host.tsx index 073490d..e7a6a33 100644 --- a/components/dialog-host.tsx +++ b/components/dialog-host.tsx @@ -1,5 +1,7 @@ "use client" +import * as React from "react" +import { RiLoaderLine } from "@remixicon/react" import { AlertDialog, AlertDialogAction, @@ -10,6 +12,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" +import { runDialogAction } from "@/lib/feedback/dialog-action" import { useDialogController } from "@/lib/feedback/dialog" import type { DialogInstance } from "@/lib/feedback/dialog" import { cn } from "@/lib/utils" @@ -19,77 +22,85 @@ function positiveButtonVariant(dialog: DialogInstance) { return dialog.tone === "destructive" ? "destructive" : dialog.tone === "warning" ? "secondary" : "default" } -function handleAction( - controller: ReturnType, - dialog: DialogInstance, - action?: DialogInstance["onPositiveClick"] | DialogInstance["onNegativeClick"], -) { - if (!action) { - controller.close(dialog.id) - return - } - - const run = async () => { - try { - const result = await action() - if (result === false) return - controller.close(dialog.id) - } catch (error) { - console.error(error) - } - } - - run() -} - export function DialogHost() { const controller = useDialogController() const dialogs = controller.dialogs + const pendingDialogIdsRef = React.useRef(new Set()) + const [pendingDialogIds, setPendingDialogIds] = React.useState>(new Set()) return ( <> - {dialogs.map((dialog) => ( - controller.setOpen(dialog.id, value)}> - - - {dialog.title && {dialog.title}} - {dialog.content && {dialog.content}} - - - {dialog.negativeText && ( - + {dialogs.map((dialog) => { + const isPending = pendingDialogIds.has(dialog.id) + + return ( + controller.setOpen(dialog.id, value)} + > + + + {dialog.title && {dialog.title}} + {dialog.content && {dialog.content}} + + + {dialog.negativeText && ( + + + + )} + - - )} - - - - - - - ))} + + + + + ) + })} ) } diff --git a/lib/feedback/dialog-action.js b/lib/feedback/dialog-action.js new file mode 100644 index 0000000..f6e6576 --- /dev/null +++ b/lib/feedback/dialog-action.js @@ -0,0 +1,27 @@ +export async function runDialogAction({ dialogId, pendingIds, setPendingIds, action, close, onError }) { + if (!action) { + close() + return true + } + + if (pendingIds.has(dialogId)) { + return false + } + + pendingIds.add(dialogId) + setPendingIds(new Set(pendingIds)) + + try { + const result = await action() + if (result !== false) { + close() + } + return true + } catch (error) { + onError?.(error) + return false + } finally { + pendingIds.delete(dialogId) + setPendingIds(new Set(pendingIds)) + } +} diff --git a/lib/feedback/dialog-action.ts b/lib/feedback/dialog-action.ts new file mode 100644 index 0000000..93e1207 --- /dev/null +++ b/lib/feedback/dialog-action.ts @@ -0,0 +1,45 @@ +type DialogActionResult = void | boolean | Promise + +interface RunDialogActionOptions { + dialogId: string + pendingIds: Set + setPendingIds: (pendingIds: Set) => void + action?: () => DialogActionResult + close: () => void + onError?: (error: unknown) => void +} + +export async function runDialogAction({ + dialogId, + pendingIds, + setPendingIds, + action, + close, + onError, +}: RunDialogActionOptions): Promise { + if (!action) { + close() + return true + } + + if (pendingIds.has(dialogId)) { + return false + } + + pendingIds.add(dialogId) + setPendingIds(new Set(pendingIds)) + + try { + const result = await action() + if (result !== false) { + close() + } + return true + } catch (error) { + onError?.(error) + return false + } finally { + pendingIds.delete(dialogId) + setPendingIds(new Set(pendingIds)) + } +} diff --git a/tests/lib/dialog-action.test.ts b/tests/lib/dialog-action.test.ts new file mode 100644 index 0000000..4d9990c --- /dev/null +++ b/tests/lib/dialog-action.test.ts @@ -0,0 +1,75 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { runDialogAction } from "../../lib/feedback/dialog-action.js" + +test("runDialogAction ignores duplicate submissions while the current action is pending", async () => { + const pendingIds = new Set() + const pendingSnapshots: string[][] = [] + let actionCalls = 0 + let closeCalls = 0 + let resolveAction: (() => void) | undefined + + const firstRun = runDialogAction({ + dialogId: "dialog-1", + pendingIds, + setPendingIds: (nextPendingIds) => { + pendingSnapshots.push(Array.from(nextPendingIds)) + }, + action: () => + new Promise((resolve) => { + actionCalls += 1 + resolveAction = resolve + }), + close: () => { + closeCalls += 1 + }, + }) + + const duplicateRunStarted = await runDialogAction({ + dialogId: "dialog-1", + pendingIds, + setPendingIds: () => {}, + action: () => { + actionCalls += 1 + }, + close: () => { + closeCalls += 1 + }, + }) + + assert.equal(duplicateRunStarted, false) + assert.equal(actionCalls, 1) + assert.equal(closeCalls, 0) + + resolveAction?.() + const firstRunStarted = await firstRun + + assert.equal(firstRunStarted, true) + assert.equal(closeCalls, 1) + assert.deepEqual(pendingSnapshots, [["dialog-1"], []]) + assert.equal(pendingIds.size, 0) +}) + +test("runDialogAction keeps the dialog open when the action explicitly returns false", async () => { + const pendingIds = new Set() + const pendingSnapshots: string[][] = [] + let closeCalls = 0 + + const started = await runDialogAction({ + dialogId: "dialog-2", + pendingIds, + setPendingIds: (nextPendingIds) => { + pendingSnapshots.push(Array.from(nextPendingIds)) + }, + action: async () => false, + close: () => { + closeCalls += 1 + }, + }) + + assert.equal(started, true) + assert.equal(closeCalls, 0) + assert.deepEqual(pendingSnapshots, [["dialog-2"], []]) + assert.equal(pendingIds.size, 0) +})