From 87b4fde1e910ccd2226e78a9aa50b126629307c0 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 6 Oct 2026 09:38:37 -0700 Subject: [PATCH] Delete without "Are you sure?": Undo instead Deleting from a list or an object's page no longer asks first. The objects vanish at once and a toast offers Undo for 8 seconds; the delete is sent to the server only when that time is up, so Undo just forgets it and nothing has to be re-created. A delete the server refuses brings the objects back with the reason. Leaving the page sends what is still waiting. "All matching" bulk deletes, which can reach past the page, still ask first. Toasts now honor their own duration; they all closed after 5 seconds, which cut short the Security page's 10-second Undo. Admin UX roadmap, item 9. --- src/components/common/undoableDelete.tsx | 125 +++++++++++++++++++++++ src/components/forms/DynamicForm.tsx | 99 ++++-------------- src/components/lists/DynamicList.tsx | 86 +++++++++------- src/hooks/use-toast.ts | 3 +- src/i18n/en.json | 8 ++ src/lib/pendingDeletes.test.ts | 100 ++++++++++++++++++ src/lib/pendingDeletes.ts | 121 ++++++++++++++++++++++ 7 files changed, 425 insertions(+), 117 deletions(-) create mode 100644 src/components/common/undoableDelete.tsx create mode 100644 src/lib/pendingDeletes.test.ts create mode 100644 src/lib/pendingDeletes.ts diff --git a/src/components/common/undoableDelete.tsx b/src/components/common/undoableDelete.tsx new file mode 100644 index 0000000..d6e0503 --- /dev/null +++ b/src/components/common/undoableDelete.tsx @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Delete without "Are you sure?" (admin UX roadmap, item 9). The + * objects vanish at once, a toast offers Undo, and the delete is sent when + * the toast's time is up. A delete the server refuses brings the objects + * back with the reason. + */ + +import i18n from '@/i18n'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/hooks/use-toast'; +import { friendlySetError } from '@/lib/jmapErrors'; +import { + flushPendingDeletes, + heldCount, + scheduleDelete, + UNDO_MS, + type DeleteResult, + type Scheduled, +} from '@/lib/pendingDeletes'; +import { getAccountId, jmapSet } from '@/services/jmap/client'; +import { useAuthStore } from '@/stores/authStore'; +import type { JmapSetError, JmapSetResponse } from '@/types/jmap'; + +/** Sends the delete in batches the server accepts. */ +async function commit(objectName: string, ids: string[]): Promise { + const accountId = getAccountId(objectName); + const batchSize = Math.max(1, useAuthStore.getState().maxObjectsInSet || ids.length); + const destroyed: string[] = []; + const errors: Record = {}; + for (let i = 0; i < ids.length; i += batchSize) { + const batch = ids.slice(i, i + batchSize); + const responses = await jmapSet(objectName, accountId, { destroy: batch }); + const entry = responses.find(([name]) => name === `${objectName}/set`); + if (!entry) { + const r = responses[0]?.[1] as { type?: string; description?: string } | undefined; + const error: JmapSetError = { type: r?.type ?? 'serverFail', description: r?.description }; + for (const id of batch) errors[id] = error; + continue; + } + const resp = entry[1] as unknown as JmapSetResponse; + destroyed.push(...(resp.destroyed ?? [])); + Object.assign(errors, resp.notDestroyed ?? {}); + for (const id of batch) { + if (!destroyed.includes(id) && !errors[id]) { + errors[id] = { + type: 'notConfirmed', + description: i18n.t( + 'list.deleteNotConfirmed', + 'Delete failed: item was not confirmed as destroyed by the server.', + ), + }; + } + } + } + return { destroyed, errors }; +} + +function reportFailures(errors: Record) { + const list = Object.values(errors); + if (list.length === 0) return; + const reasons = [...new Set(list.map((e) => friendlySetError(e)))]; + toast({ + variant: 'destructive', + title: i18n.t('undoDelete.failed', { + count: list.length, + defaultValue_one: 'Not deleted', + defaultValue_other: '{{count}} not deleted', + }), + description: reasons.join(' '), + }); +} + +export interface UndoableDelete { + objectName: string; + ids: string[]; + /** What was deleted, in words: "the domain example.org", "3 accounts". */ + what: string; + /** Runs once the server has deleted at least one of them. */ + onDeleted?: (result: DeleteResult) => void; +} + +/** Hides the objects, offers Undo for UNDO_MS, then deletes them. */ +export function deleteWithUndo({ objectName, ids, what, onDeleted }: UndoableDelete): Scheduled { + const scheduled = scheduleDelete(objectName, ids, commit); + const { dismiss } = toast({ + title: i18n.t('undoDelete.deleted', 'Deleted {{what}}', { what }), + duration: UNDO_MS, + description: ( + + ), + }); + void scheduled.settled.then((result) => { + if (!result) return; + reportFailures(result.errors); + if (result.destroyed.length > 0) onDeleted?.(result); + }); + return scheduled; +} + +// Leaving the page sends whatever is still waiting, and asks the browser to +// hold on while it goes, so a delete is neither lost nor done silently later. +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', (e) => { + if (heldCount() === 0) return; + void flushPendingDeletes(); + e.preventDefault(); + }); +} diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index b2c1879..0c0a83d 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -53,6 +53,7 @@ import { resolveSchema, resolveList, resolveForm, + getDisplayProperty, resolveVariantForm, buildCreateDefaults, buildEmbeddedDefaults, @@ -63,6 +64,7 @@ import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors'; import { coerceLabel } from '@/lib/objectOptions'; import { SECRET_MASK } from '@/lib/jmapUtils'; import { toast } from '@/hooks/use-toast'; +import { deleteWithUndo } from '@/components/common/undoableDelete'; import { logFormChange } from '@/lib/debug'; import { FieldWidget } from '@/components/forms/FieldWidget'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; @@ -115,7 +117,6 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic const [serverCreatedProps, setServerCreatedProps] = useState | null>(null); const [createdObjectId, setCreatedObjectId] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [resetOpen, setResetOpen] = useState(false); const currentFields = useMemo((): Fields | null => { @@ -680,41 +681,22 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic t, ]); - const handleDelete = useCallback(async () => { + // inbuxa: no "Are you sure?"; the delete waits behind an Undo toast (admin UX roadmap, item 9). + const handleDelete = useCallback(() => { if (!resolved || !objectId) return; - - setSaving(true); - setGeneralError(null); - - try { - const { obj } = resolved; - const accountId = getAccountId(obj.objectName); - const responses = await jmapSet(obj.objectName, accountId, { destroy: [objectId] }); - const setResponse = responses[responses.length - 1]; - const setResult = setResponse[1] as unknown as JmapSetResponse; - - if (setResult.destroyed && setResult.destroyed.includes(objectId)) { - const list = resolveList(schema!, viewName, obj.objectName); - const label = list?.singularName ?? obj.objectType.description; - toast({ - title: t('form.deletedSuccess', '{{name}} deleted successfully', { - name: label.charAt(0).toUpperCase() + label.slice(1), - }), - variant: 'success', - }); - setOriginalData({ ...formData }); - const section = viewToSection[viewName] ?? ''; - navigate(`/${section}/${viewName}`); - } else if (setResult.notDestroyed && setResult.notDestroyed[objectId]) { - const error = setResult.notDestroyed[objectId]; - setGeneralError(friendlySetError(error)); - } - } catch (err) { - setGeneralError(err instanceof Error ? err.message : t('form.failedToDelete', 'Failed to delete.')); - } finally { - setSaving(false); - } - }, [resolved, viewName, objectId, viewToSection, navigate, schema, formData, t]); + const { obj } = resolved; + const list = resolveList(schema!, viewName, obj.objectName); + const label = (list?.singularName ?? obj.objectType.description).toLowerCase(); + const shown = formData[getDisplayProperty(schema!, obj.objectName)]; + deleteWithUndo({ + objectName: obj.objectName, + ids: [objectId], + what: typeof shown === 'string' && shown ? shown : label, + }); + setOriginalData({ ...formData }); + const section = viewToSection[viewName] ?? ''; + navigate(`/${section}/${viewName}`); + }, [resolved, viewName, objectId, viewToSection, navigate, schema, formData]); const canEdit = isCreate || canUpdateObject(viewName); const canDelete = !isCreate && !isSingleton && canDestroyObject(viewName); @@ -948,7 +930,7 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic
{canDelete && ( - @@ -1047,51 +1029,6 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic - { - if (!open) setDeleteConfirmOpen(false); - }} - > - - - {(() => { - const list = resolved ? resolveList(schema, viewName, resolved.obj.objectName) : null; - const label = ( - list?.singularName ?? - resolved?.obj.objectType.description ?? - t('form.item', 'item') - ).toLowerCase(); - const titleLabel = label.charAt(0).toUpperCase() + label.slice(1); - return ( - <> - {t('form.deleteTitle', 'Delete {{name}}?', { name: titleLabel })} - - {t( - 'form.deleteDescription', - 'This action cannot be undone. This will permanently delete this {{name}}.', - { name: label }, - )} - - - ); - })()} - - - {t('common.cancel', 'Cancel')} - { - setDeleteConfirmOpen(false); - handleDelete(); - }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {t('common.delete', 'Delete')} - - - - - { diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 27492a0..e9143f2 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -59,6 +59,8 @@ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/component import { ObjectPicker } from '@/components/common/ObjectPicker'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { toast } from '@/hooks/use-toast'; +import { deleteWithUndo } from '@/components/common/undoableDelete'; +import { isPendingDelete, usePendingDeletes } from '@/lib/pendingDeletes'; import { friendlySetError } from '@/lib/jmapErrors'; import { coerceLabel } from '@/lib/objectOptions'; import { buildJmapFilter } from '@/lib/listFilter'; @@ -501,9 +503,12 @@ export function DynamicList({ viewName }: DynamicListProps) { // inbuxa: a server that can't count (logs past the first page) leaves // total out; the last page's total must not stand in for it - setTotal(queryData.total ?? null); + // inbuxa: objects waiting behind an Undo toast are already gone here (item 9) + const listed = getData.list ?? []; + const shown = listed.filter((item) => !isPendingDelete(obj.objectName, item.id as string)); + setTotal(queryData.total == null ? null : queryData.total - (listed.length - shown.length)); - setItems(getData.list ?? []); + setItems(shown); setSelectedIds(new Set()); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -514,6 +519,15 @@ export function DynamicList({ viewName }: DynamicListProps) { [resolved, schema, buildFilter, buildSort, t], ); + // inbuxa: reload when a delete of this kind is held, undone or sent (item 9) + const pendingVersion = usePendingDeletes((s) => (resolved ? (s.versions[resolved.obj.objectName] ?? 0) : 0)); + useEffect(() => { + if (pendingVersion === 0) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchData(currentAnchor, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingVersion]); + useEffect(() => { if (!resolved?.list) return; // eslint-disable-next-line react-hooks/set-state-in-effect @@ -711,15 +725,21 @@ export function DynamicList({ viewName }: DynamicListProps) { const allErrors: Record = {}; if (action.type === 'delete') { - for (let i = 0; i < targetIds.length; i += batchSize) { - const batch = targetIds.slice(i, i + batchSize); - const raw = await jmapSet(obj.objectName, accountId, { destroy: batch }); - const resp = parseSetResponse(raw); - if (resp) { - totalSuccess += resp.destroyed?.length ?? 0; - if (resp.notDestroyed) Object.assign(allErrors, resp.notDestroyed); - } - } + // inbuxa: held behind an Undo toast, which reports what the server refused (item 9) + const names = resolved.list; + deleteWithUndo({ + objectName: obj.objectName, + ids: targetIds, + what: t('undoDelete.count', { + count: targetIds.length, + name: targetIds.length === 1 ? names?.singularName : names?.pluralName, + defaultValue_one: '{{count}} {{name}}', + defaultValue_other: '{{count}} {{name}}', + }), + }); + setSelectedIds(new Set()); + setSelectAllMode(false); + return; } else if (action.type === 'setProperty') { for (let i = 0; i < targetIds.length; i += batchSize) { const batch = targetIds.slice(i, i + batchSize); @@ -739,7 +759,7 @@ export function DynamicList({ viewName }: DynamicListProps) { const summary = bulkResultSummary( totalSuccess, Object.keys(allErrors).length > 0 ? allErrors : null, - action.type === 'delete' ? 'delete' : 'update', + 'update', t, ); if (summary) { @@ -802,34 +822,23 @@ export function DynamicList({ viewName }: DynamicListProps) { break; } case 'delete': { - try { - setLoading(true); - const accountId = getAccountId(obj.objectName); - const raw = await jmapSet(obj.objectName, accountId, { - destroy: [itemId], - }); - const resp = parseSetResponse(raw); - if (resp?.notDestroyed?.[itemId]) { - setError(friendlySetError(resp.notDestroyed[itemId])); - } else if (resp?.destroyed?.includes(itemId)) { - fetchData(currentAnchor, 0); - } else { - setError( - t('list.deleteNotConfirmed', 'Delete failed: item was not confirmed as destroyed by the server.'), - ); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + // inbuxa: no "Are you sure?"; held behind an Undo toast (item 9) + const shown = schema ? item[getDisplayProperty(schema, obj.objectName)] : undefined; + deleteWithUndo({ + objectName: obj.objectName, + ids: [itemId], + what: + typeof shown === 'string' && shown + ? shown + : (resolved.list?.singularName ?? obj.objectType.description).toLowerCase(), + }); break; } case 'separator': break; } }, - [resolved, viewName, viewToSection, navigate, fetchData, currentAnchor, t], + [resolved, schema, viewName, viewToSection, navigate, fetchData, currentAnchor], ); const handleRowClick = useCallback( @@ -1116,7 +1125,8 @@ export function DynamicList({ viewName }: DynamicListProps) { } const isDestructive = action.type === 'delete'; - const needsConfirmation = action.type === 'delete' || action.type === 'setProperty'; + // inbuxa: a delete offers Undo instead (item 9); changing a property still asks + const needsConfirmation = action.type === 'setProperty'; return ( { + // inbuxa: deleting the rows you ticked offers Undo instead of asking (item 9); + // "all matching" can reach far past the page, so it still asks first + if (isDestructive && !selectAllMode) { + void executeMassAction(action); + return; + } setConfirmAction({ label: t('list.actionWithCount', '{{action}} ({{count}} {{name}})', { action: action.label, diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index 29aba80..1bf2c31 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -159,7 +159,8 @@ function toast({ ...props }: Toast) { }, }); - setTimeout(dismiss, TOAST_AUTO_DISMISS_MS); + // inbuxa: honor the toast's own duration (an Undo toast stays up longer). + setTimeout(dismiss, props.duration ?? TOAST_AUTO_DISMISS_MS); return { id: id, diff --git a/src/i18n/en.json b/src/i18n/en.json index 1e84aba..cdeb8cd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2141,5 +2141,13 @@ "sessions": "{{succeeded}} secure deliveries, {{failed}} failed", "failedSessions_one": "({{count}} connection", "failedSessions_other": "({{count}} connections" + }, + "undoDelete": { + "failed_one": "Not deleted", + "failed_other": "{{count}} not deleted", + "deleted": "Deleted {{what}}", + "undo": "Undo", + "count_one": "{{count}} {{name}}", + "count_other": "{{count}} {{name}}" } } diff --git a/src/lib/pendingDeletes.test.ts b/src/lib/pendingDeletes.test.ts new file mode 100644 index 0000000..efb9b8d --- /dev/null +++ b/src/lib/pendingDeletes.test.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + flushPendingDeletes, + heldCount, + isPendingDelete, + scheduleDelete, + usePendingDeletes, + type Commit, +} from './pendingDeletes'; + +const ok: Commit = async (_object, ids) => ({ destroyed: ids, errors: {} }); + +describe('scheduleDelete', () => { + beforeEach(() => { + vi.useFakeTimers(); + usePendingDeletes.setState({ pending: {}, versions: {} }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('hides the objects at once and sends nothing until the time is up', async () => { + const commit = vi.fn(ok); + const s = scheduleDelete('x:Domain', ['a', 'b'], commit, 1000); + expect(isPendingDelete('x:Domain', 'a')).toBe(true); + expect(isPendingDelete('x:Account', 'a')).toBe(false); + await vi.advanceTimersByTimeAsync(999); + expect(commit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(commit).toHaveBeenCalledWith('x:Domain', ['a', 'b']); + expect(await s.settled).toEqual({ destroyed: ['a', 'b'], errors: {} }); + expect(isPendingDelete('x:Domain', 'a')).toBe(false); + expect(heldCount()).toBe(0); + }); + + it('undo in time forgets the delete and shows the objects again', async () => { + const commit = vi.fn(ok); + const s = scheduleDelete('x:Domain', ['a'], commit, 1000); + expect(s.undo()).toBe(true); + await vi.advanceTimersByTimeAsync(2000); + expect(commit).not.toHaveBeenCalled(); + expect(await s.settled).toBeNull(); + expect(isPendingDelete('x:Domain', 'a')).toBe(false); + }); + + it('undo after the delete was sent does nothing', async () => { + const s = scheduleDelete('x:Domain', ['a'], ok, 1000); + await vi.advanceTimersByTimeAsync(1000); + await s.settled; + expect(s.undo()).toBe(false); + }); + + it('bumps the version for its kind of object, so lists reload', async () => { + scheduleDelete('x:Domain', ['a'], ok, 1000); + expect(usePendingDeletes.getState().versions['x:Domain']).toBe(1); + await vi.advanceTimersByTimeAsync(1000); + expect(usePendingDeletes.getState().versions['x:Domain']).toBe(2); + expect(usePendingDeletes.getState().versions['x:Account']).toBeUndefined(); + }); + + it('reports a refused or failed delete, and shows the objects again', async () => { + const refused = scheduleDelete( + 'x:Domain', + ['a'], + async () => ({ destroyed: [], errors: { a: { type: 'objectIsLinked' } } }), + 1000, + ); + const thrown = scheduleDelete( + 'x:Account', + ['b'], + async () => { + throw new Error('offline'); + }, + 1000, + ); + await vi.advanceTimersByTimeAsync(1000); + expect((await refused.settled)?.errors.a.type).toBe('objectIsLinked'); + expect((await thrown.settled)?.errors.b).toEqual({ type: 'serverFail', description: 'offline' }); + expect(isPendingDelete('x:Domain', 'a')).toBe(false); + expect(isPendingDelete('x:Account', 'b')).toBe(false); + }); + + it('flush sends every waiting delete now', async () => { + const commit = vi.fn(ok); + scheduleDelete('x:Domain', ['a'], commit, 60_000); + scheduleDelete('x:Account', ['b'], commit, 60_000); + expect(heldCount()).toBe(2); + await flushPendingDeletes(); + expect(commit).toHaveBeenCalledTimes(2); + expect(heldCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(commit).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/pendingDeletes.ts b/src/lib/pendingDeletes.ts new file mode 100644 index 0000000..852c010 --- /dev/null +++ b/src/lib/pendingDeletes.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Undo instead of "Are you sure?" (admin UX roadmap, item 9). A + * delete is held here for UNDO_MS before it reaches the server, so Undo + * only has to forget it: nothing is re-created, and nothing is lost that a + * re-create couldn't bring back (secrets, ids, history). While a delete is + * held its objects are hidden from lists. + */ + +import { create } from 'zustand'; +import type { JmapSetError } from '@/types/jmap'; + +/** How long Undo is offered before the delete is sent. */ +export const UNDO_MS = 8_000; + +export interface DeleteResult { + destroyed: string[]; + errors: Record; +} + +/** Sends the delete. Injected so the hold-and-release logic can be tested without a server. */ +export type Commit = (objectName: string, ids: string[]) => Promise; + +interface State { + /** objectName → ids waiting to be deleted. */ + pending: Record; + /** objectName → bumped whenever its pending set changes or a delete lands, so lists reload. */ + versions: Record; +} + +export const usePendingDeletes = create(() => ({ pending: {}, versions: {} })); + +function change(objectName: string, update: (ids: string[]) => string[]) { + usePendingDeletes.setState((s) => ({ + pending: { ...s.pending, [objectName]: update(s.pending[objectName] ?? []) }, + versions: { ...s.versions, [objectName]: (s.versions[objectName] ?? 0) + 1 }, + })); +} + +/** Whether this object is waiting to be deleted, so a list should leave it out. */ +export function isPendingDelete(objectName: string, id: string): boolean { + return usePendingDeletes.getState().pending[objectName]?.includes(id) ?? false; +} + +interface Held { + objectName: string; + ids: string[]; + timer: ReturnType; + run: () => Promise; +} + +const held = new Set(); + +export interface Scheduled { + /** Forgets the delete, if it hasn't been sent yet. Returns whether it was in time. */ + undo: () => boolean; + /** Resolves once the delete was sent (with what happened) or undone (null). */ + settled: Promise; +} + +/** Holds a delete for `delayMs`, then sends it with `commit`. */ +export function scheduleDelete( + objectName: string, + ids: string[], + commit: Commit, + delayMs: number = UNDO_MS, +): Scheduled { + let resolve!: (r: DeleteResult | null) => void; + const settled = new Promise((r) => (resolve = r)); + change(objectName, (pending) => [...pending, ...ids.filter((id) => !pending.includes(id))]); + + const release = () => change(objectName, (pending) => pending.filter((id) => !ids.includes(id))); + + const entry: Held = { + objectName, + ids, + timer: setTimeout(() => void entry.run(), delayMs), + run: async () => { + held.delete(entry); + clearTimeout(entry.timer); + let result: DeleteResult; + try { + result = await commit(objectName, ids); + } catch (e) { + const error: JmapSetError = { type: 'serverFail', description: e instanceof Error ? e.message : String(e) }; + result = { destroyed: [], errors: Object.fromEntries(ids.map((id) => [id, error])) }; + } + release(); + resolve(result); + return result; + }, + }; + held.add(entry); + + return { + undo: () => { + if (!held.has(entry)) return false; + held.delete(entry); + clearTimeout(entry.timer); + release(); + resolve(null); + return true; + }, + settled, + }; +} + +/** How many deletes are waiting. */ +export function heldCount(): number { + return held.size; +} + +/** Sends every waiting delete now (leaving the page, signing out). */ +export function flushPendingDeletes(): Promise { + return Promise.all([...held].map((h) => h.run())); +} -- 2.54.0