fix: prevent duplicate dialog delete submissions

This commit is contained in:
马登山
2026-04-27 09:08:47 +08:00
parent ff71a89060
commit 6649168d70
4 changed files with 216 additions and 58 deletions
+69 -58
View File
@@ -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<typeof useDialogController>,
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<string>())
const [pendingDialogIds, setPendingDialogIds] = React.useState<Set<string>>(new Set())
return (
<>
{dialogs.map((dialog) => (
<AlertDialog key={dialog.id} open={dialog.open} onOpenChange={(value) => controller.setOpen(dialog.id, value)}>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
{dialog.title && <AlertDialogTitle>{dialog.title}</AlertDialogTitle>}
{dialog.content && <AlertDialogDescription>{dialog.content}</AlertDialogDescription>}
</AlertDialogHeader>
<AlertDialogFooter>
{dialog.negativeText && (
<AlertDialogCancel asChild>
{dialogs.map((dialog) => {
const isPending = pendingDialogIds.has(dialog.id)
return (
<AlertDialog
key={dialog.id}
open={dialog.open}
onOpenChange={(value) => controller.setOpen(dialog.id, value)}
>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
{dialog.title && <AlertDialogTitle>{dialog.title}</AlertDialogTitle>}
{dialog.content && <AlertDialogDescription>{dialog.content}</AlertDialogDescription>}
</AlertDialogHeader>
<AlertDialogFooter>
{dialog.negativeText && (
<AlertDialogCancel asChild>
<button
type="button"
disabled={isPending}
className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto text-foreground")}
onClick={(e) => {
e.preventDefault()
void runDialogAction({
dialogId: dialog.id,
pendingIds: pendingDialogIdsRef.current,
setPendingIds: setPendingDialogIds,
action: dialog.onNegativeClick,
close: () => controller.close(dialog.id),
onError: (error) => {
console.error(error)
},
})
}}
>
{dialog.negativeText}
</button>
</AlertDialogCancel>
)}
<AlertDialogAction asChild>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto text-foreground")}
disabled={isPending}
className={cn(
buttonVariants({ variant: positiveButtonVariant(dialog) }),
"w-full sm:w-auto",
positiveButtonVariant(dialog) === "destructive" && "text-white",
)}
onClick={(e) => {
e.preventDefault()
handleAction(controller, dialog, dialog.onNegativeClick)
void runDialogAction({
dialogId: dialog.id,
pendingIds: pendingDialogIdsRef.current,
setPendingIds: setPendingDialogIds,
action: dialog.onPositiveClick,
close: () => controller.close(dialog.id),
onError: (error) => {
console.error(error)
},
})
}}
>
{dialog.negativeText}
{isPending ? <RiLoaderLine className="size-4 animate-spin" aria-hidden="true" /> : null}
<span>{dialog.positiveText || "Confirm"}</span>
</button>
</AlertDialogCancel>
)}
<AlertDialogAction asChild>
<button
type="button"
className={cn(
buttonVariants({ variant: positiveButtonVariant(dialog) }),
"w-full sm:w-auto",
positiveButtonVariant(dialog) === "destructive" && "text-white",
)}
onClick={(e) => {
e.preventDefault()
handleAction(controller, dialog, dialog.onPositiveClick)
}}
>
{dialog.positiveText || "Confirm"}
</button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
))}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
})}
</>
)
}
+27
View File
@@ -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))
}
}
+45
View File
@@ -0,0 +1,45 @@
type DialogActionResult = void | boolean | Promise<void | boolean>
interface RunDialogActionOptions {
dialogId: string
pendingIds: Set<string>
setPendingIds: (pendingIds: Set<string>) => void
action?: () => DialogActionResult
close: () => void
onError?: (error: unknown) => void
}
export async function runDialogAction({
dialogId,
pendingIds,
setPendingIds,
action,
close,
onError,
}: RunDialogActionOptions): Promise<boolean> {
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))
}
}
+75
View File
@@ -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<string>()
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<void>((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<string>()
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)
})