fix(ui): show delayed busy feedback on confirm actions (#1763)

Wire ConfirmModal and BusyButton so async confirms lock immediately, show
spinner and progressive labels after duration-base, and block dismiss mid-flight.
Connect stack delete and take-down to the existing stackAction map so the dialog
is not idle until the toast.
This commit is contained in:
Anso
2026-08-03 22:14:24 -04:00
committed by GitHub
parent 0ba09ebdee
commit 5d89a10754
18 changed files with 509 additions and 46 deletions
@@ -1,8 +1,8 @@
import { Suspense } from 'react';
import { DiffEditor } from '@/lib/monacoLoader';
import { Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { BusyButton } from '@/components/ui/busy-button';
import type { ComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel';
export interface ComposeDiffPreviewDialogProps {
@@ -75,16 +75,13 @@ export function ComposeDiffPreviewDialog({
</Button>
}
primary={
<Button
<BusyButton
size="sm"
pending={confirming}
onClick={() => onConfirm()}
disabled={confirming}
>
{confirming && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
)}
{actionLabel}
</Button>
</BusyButton>
}
/>
</Modal>
+2
View File
@@ -1056,6 +1056,8 @@ export default function EditorLayout() {
<ShellOverlays
overlayState={overlayState}
stackActions={stackActions}
stackActionMap={stackActionMap}
stackFiles={files}
isDarkMode={isDarkMode}
isAdmin={isAdmin}
can={can}
@@ -7,9 +7,17 @@ export interface DeleteStackDialogProps {
onOpenChange: (open: boolean) => void;
stackName: string | null;
onConfirm: (pruneVolumes: boolean) => void | Promise<void>;
/** True while the stack delete request owns the flow (from stackActionMap). */
confirming?: boolean;
}
export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }: DeleteStackDialogProps) {
export function DeleteStackDialog({
open,
onOpenChange,
stackName,
onConfirm,
confirming = false,
}: DeleteStackDialogProps) {
const [pruneVolumes, setPruneVolumes] = useState(false);
const handleOpenChange = (next: boolean) => {
@@ -42,6 +50,8 @@ export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }:
description={`Confirm deletion of ${stackName ?? 'stack'}.`}
hint={pruneVolumes ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'}
confirmLabel="Delete"
busyConfirmLabel="Deleting..."
confirming={confirming}
onConfirm={() => onConfirm(pruneVolumes)}
>
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
@@ -49,6 +59,7 @@ export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }:
<Checkbox
id="prune-volumes"
checked={pruneVolumes}
disabled={confirming}
onCheckedChange={(v) => setPruneVolumes(v === true)}
/>
<label htmlFor="prune-volumes" className="text-sm text-muted-foreground cursor-pointer select-none">
@@ -19,10 +19,16 @@ import type { OverlayState } from './hooks/useOverlayState';
import type { StackActionsHook } from './hooks/useStackActions';
import type { PermissionAction } from '@/context/AuthContext';
import type { useComposeReapplyAction } from '../FleetView/hooks/useComposeReapplyAction';
import type { StackAction } from './EditorView';
import { resolveStackFileKey } from './hooks/resolveStackFileKey';
interface ShellOverlaysProps {
overlayState: OverlayState;
stackActions: StackActionsHook;
/** Filename-keyed busy map from stack list state (not the stackActions hook). */
stackActionMap: Record<string, StackAction>;
/** Stack filenames for resolveStackFileKey (overlay holds bare names). */
stackFiles: string[];
isDarkMode: boolean;
isAdmin: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
@@ -41,6 +47,8 @@ interface ShellOverlaysProps {
export function ShellOverlays({
overlayState,
stackActions,
stackActionMap,
stackFiles,
isDarkMode,
isAdmin,
can,
@@ -72,6 +80,13 @@ export function ShellOverlays({
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} = overlayState;
const isDeleteConfirming =
stackToDelete != null &&
stackActionMap[resolveStackFileKey(stackFiles, stackToDelete)] === 'delete';
const isTakeDownConfirming =
stackToTakeDown != null &&
stackActionMap[resolveStackFileKey(stackFiles, stackToTakeDown)] === 'down';
return (
<>
<DeleteStackDialog
@@ -79,6 +94,7 @@ export function ShellOverlays({
onOpenChange={(open) => { if (!open) closeDeleteDialog(); }}
stackName={stackToDelete}
onConfirm={stackActions.deleteStack}
confirming={isDeleteConfirming}
/>
<TakeDownStackDialog
@@ -87,6 +103,7 @@ export function ShellOverlays({
stackName={stackToTakeDown}
showVolumeOption={canOfferVolumeRemoval}
onConfirm={stackActions.takeDownStack}
confirming={isTakeDownConfirming}
/>
<SelfStackProtectedDialog
@@ -8,6 +8,8 @@ export interface TakeDownStackDialogProps {
stackName: string | null;
showVolumeOption: boolean;
onConfirm: (removeVolumes: boolean) => void | Promise<void>;
/** True while the take-down request owns the flow (from stackActionMap). */
confirming?: boolean;
}
export function TakeDownStackDialog({
@@ -16,6 +18,7 @@ export function TakeDownStackDialog({
stackName,
showVolumeOption,
onConfirm,
confirming = false,
}: TakeDownStackDialogProps) {
const [removeVolumes, setRemoveVolumes] = useState(false);
@@ -30,7 +33,6 @@ export function TakeDownStackDialog({
open={open}
onOpenChange={onOpenChange}
variant="destructive"
data-testid="take-down-dialog"
kicker={`${(stackName ?? 'STACK').toUpperCase()} · TAKE DOWN${removeVolumes ? '' : ' · REVERSIBLE'}`}
title={
stackName ? (
@@ -44,6 +46,8 @@ export function TakeDownStackDialog({
description="This removes running containers and compose-created networks. The stack configuration stays on disk so you can deploy again later."
hint={removeVolumes ? 'VOLUMES REMOVED' : 'VOLUMES KEPT'}
confirmLabel="Take down"
busyConfirmLabel="Taking down..."
confirming={confirming}
onConfirm={() => onConfirm(removeVolumes)}
>
{showVolumeOption && (
@@ -52,6 +56,7 @@ export function TakeDownStackDialog({
id="take-down-remove-volumes"
data-testid="take-down-remove-volumes"
checked={removeVolumes}
disabled={confirming}
onCheckedChange={(v) => setRemoveVolumes(v === true)}
/>
<label
@@ -28,4 +28,20 @@ describe('DeleteStackDialog', () => {
expect(screen.getByTitle(LONG_STACK_NAME)).toHaveTextContent(LONG_STACK_NAME);
});
it('disables Delete, Cancel, and volume checkbox while confirming', () => {
render(
<DeleteStackDialog
open
onOpenChange={vi.fn()}
stackName="web"
onConfirm={vi.fn()}
confirming
/>,
);
expect(screen.getByRole('button', { name: /Delete/i })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
expect(screen.getByRole('checkbox')).toBeDisabled();
});
});
@@ -88,4 +88,11 @@ describe('TakeDownStackDialog', () => {
await user.click(screen.getByRole('button', { name: 'Take down' }));
expect(onConfirm).toHaveBeenCalledWith(false);
});
it('disables Take down, Cancel, and volume checkbox while confirming', () => {
renderDialog(true, { confirming: true });
expect(screen.getByRole('button', { name: /Take down/i })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
expect(screen.getByTestId('take-down-remove-volumes')).toBeDisabled();
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { resolveStackFileKey } from '../resolveStackFileKey';
describe('resolveStackFileKey', () => {
const files = ['web.yml', 'api.yaml', 'plain'];
it('returns an exact filename match', () => {
expect(resolveStackFileKey(files, 'web.yml')).toBe('web.yml');
});
it('matches a bare name against a .yml file', () => {
expect(resolveStackFileKey(files, 'web')).toBe('web.yml');
});
it('matches a bare name against a .yaml file', () => {
expect(resolveStackFileKey(files, 'api')).toBe('api.yaml');
});
it('passes through when no match exists', () => {
expect(resolveStackFileKey(files, 'missing')).toBe('missing');
});
});
@@ -0,0 +1,12 @@
/**
* Map an overlay stack name (bare name or filename) to the key used in
* `stackActionMap` / stack file lists. The map is filename-keyed (e.g. `web.yml`);
* overlay state often holds the bare name (`web`).
*/
export function resolveStackFileKey(files: string[], name: string): string {
return (
files.find(
(f) => f === name || f.replace(/\.(yml|yaml)$/, '') === name,
) ?? name
);
}
@@ -30,6 +30,7 @@ import type {
MissingExternalNetworksPayload,
} from '../../stack/MissingExternalNetworksDialog';
import type { PreDeployScanImage } from '@/types/security';
import { resolveStackFileKey } from './resolveStackFileKey';
interface RunResult {
ok: boolean;
@@ -1948,10 +1949,7 @@ export function useStackActions(options: UseStackActionsOptions) {
const deleteStack = async (pruneVolumes: boolean) => {
const stackToDelete = overlayState.stackToDelete;
if (!stackToDelete) return;
const deleteKey =
stackListState.files.find(
f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete,
) ?? stackToDelete;
const deleteKey = resolveStackFileKey(stackListState.files, stackToDelete);
const canonicalName = deleteKey.replace(/\.(yml|yaml)$/, '');
if (stackListState.isStackBusy(deleteKey)) return;
stackListState.setStackAction(deleteKey, 'delete');
@@ -2010,10 +2008,7 @@ export function useStackActions(options: UseStackActionsOptions) {
overlayState.closeTakeDownDialog();
return;
}
const stackFile =
stackListState.files.find(
f => f === stackToTakeDown || f.replace(/\.(yml|yaml)$/, '') === stackToTakeDown,
) ?? stackToTakeDown;
const stackFile = resolveStackFileKey(stackListState.files, stackToTakeDown);
if (stackListState.isStackBusy(stackFile)) return;
if (openSelfStackProtectedIfNeeded(stackFile)) return;
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
import { Modal, ModalDestructiveHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { BusyButton } from '@/components/ui/busy-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast-store';
@@ -69,6 +70,7 @@ export function DeleteFileConfirm({
const protectedOk = !isProtected || confirmInput === entryName;
const deleteLabel = notEmpty ? 'Delete all' : 'Delete';
const busyDeleteLabel = notEmpty ? 'Deleting all...' : 'Deleting...';
const titleNode = entryName ? (
<>
@@ -130,18 +132,17 @@ export function DeleteFileConfirm({
</Button>
}
primary={
<Button
<BusyButton
variant="destructive"
size="sm"
data-testid="delete-confirm-btn"
onClick={handleDelete}
disabled={deleting || !protectedOk}
pending={deleting}
busyLabel={busyDeleteLabel}
disabled={!protectedOk}
>
{deleting && (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
)}
{deleteLabel}
</Button>
</BusyButton>
}
/>
</Modal>
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { BusyButton } from '../busy-button';
import { DURATION_BASE_MS } from '@/hooks/useVisualBusy';
describe('BusyButton', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('disables immediately when pending without showing spinner yet', () => {
render(
<BusyButton pending busyLabel="Saving...">
Save
</BusyButton>,
);
const btn = screen.getByRole('button', { name: /Save/i });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('aria-busy', 'true');
expect(btn.querySelector('.animate-spin')).toBeNull();
});
it('shows spinner and progressive label after the visual delay', () => {
render(
<BusyButton pending busyLabel="Saving...">
Save
</BusyButton>,
);
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS);
});
expect(screen.getByRole('button', { name: /Saving/i })).toBeDisabled();
expect(document.querySelector('.animate-spin')).not.toBeNull();
});
it('signals busy via aria-busy and label without requiring spin animation', () => {
render(
<BusyButton pending busyLabel="Deleting...">
Delete
</BusyButton>,
);
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS);
});
const btn = screen.getByRole('button', { name: /Deleting/i });
expect(btn).toHaveAttribute('aria-busy', 'true');
expect(btn).toHaveTextContent('Deleting...');
// Spinner is enhancement only; the accessible name and aria-busy are primary.
expect(btn.getAttribute('aria-busy')).toBe('true');
});
});
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'react';
import { ConfirmModal } from '../modal';
import { DURATION_BASE_MS } from '@/hooks/useVisualBusy';
function renderConfirm(props: Partial<ComponentProps<typeof ConfirmModal>> = {}) {
const onOpenChange = vi.fn();
const onConfirm = vi.fn();
render(
<ConfirmModal
open
onOpenChange={onOpenChange}
kicker="TEST"
title="Confirm?"
confirmLabel="Delete"
busyConfirmLabel="Deleting..."
onConfirm={onConfirm}
{...props}
/>,
);
return { onOpenChange, onConfirm };
}
describe('ConfirmModal busy behaviour', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps the dialog open for async onConfirm', async () => {
let resolveConfirm!: () => void;
const pending = new Promise<void>((resolve) => {
resolveConfirm = resolve;
});
const onConfirm = vi.fn(() => pending);
const onOpenChange = vi.fn();
render(
<ConfirmModal
open
onOpenChange={onOpenChange}
kicker="TEST"
title="Confirm?"
confirmLabel="Delete"
onConfirm={onConfirm}
confirming={false}
/>,
);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
// Radix would otherwise close; preventDefault keeps it open so parent can set confirming.
expect(onOpenChange).not.toHaveBeenCalledWith(false);
resolveConfirm();
await act(async () => {
await pending;
});
});
it('disables only Confirm when confirmDisabled is set without confirming', () => {
renderConfirm({ confirmDisabled: true, confirming: false });
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).not.toBeDisabled();
});
it('disables both Confirm and Cancel when confirming', () => {
renderConfirm({ confirming: true });
expect(screen.getByRole('button', { name: /Delete/i })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
});
it('does not call onOpenChange(false) on Escape while confirming', async () => {
const { onOpenChange } = renderConfirm({ confirming: true });
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.keyboard('{Escape}');
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it('shows progressive busy label after the delay when confirming', async () => {
renderConfirm({ confirming: true });
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS);
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Deleting/i })).toBeInTheDocument();
});
});
});
+4 -2
View File
@@ -93,10 +93,12 @@ AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayNam
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
>(({ className, asChild, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
asChild={asChild}
// When asChild, child (e.g. BusyButton) owns buttonVariants; do not stack defaults.
className={cn(asChild ? undefined : buttonVariants(), className)}
{...props}
/>
));
@@ -0,0 +1,75 @@
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { Button, type ButtonProps } from '@/components/ui/button';
import { useVisualBusy } from '@/hooks/useVisualBusy';
import { cn } from '@/lib/utils';
export type BusyButtonProps = ButtonProps & {
/** Immediate interaction lock. Disables the control while true. */
pending: boolean;
/**
* Progressive label once delayed visual busy is shown. When omitted, idle
* children stay visible and only the spinner (if shown) appears.
*/
busyLabel?: React.ReactNode;
};
/**
* Composition over {@link Button} for async click surfaces.
* Never use `asChild` on this component: busy chrome is multi-child content.
*/
export const BusyButton = React.forwardRef<HTMLButtonElement, BusyButtonProps>(
({ pending, busyLabel, children, disabled, className, type = 'button', ...props }, ref) => {
const { showBusy } = useVisualBusy(pending);
const useTwinLabels =
typeof children === 'string' &&
typeof busyLabel === 'string';
return (
<Button
ref={ref}
type={type}
disabled={disabled || pending}
aria-busy={pending || undefined}
className={className}
{...props}
>
{pending ? (
<span
className="inline-flex size-4 shrink-0 items-center justify-center"
aria-hidden
>
{showBusy ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.5} />
) : null}
</span>
) : null}
{useTwinLabels ? (
<span className="relative inline-grid justify-items-center">
<span
className={cn(
'col-start-1 row-start-1',
showBusy && busyLabel != null && 'invisible',
)}
aria-hidden={showBusy && busyLabel != null ? true : undefined}
>
{children}
</span>
<span
className={cn(
'col-start-1 row-start-1',
!(showBusy && busyLabel != null) && 'invisible',
)}
aria-hidden={!(showBusy && busyLabel != null) ? true : undefined}
>
{busyLabel}
</span>
</span>
) : (
<>{showBusy && busyLabel != null ? busyLabel : children}</>
)}
</Button>
);
},
);
BusyButton.displayName = 'BusyButton';
+38 -20
View File
@@ -1,6 +1,7 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import { BusyButton } from '@/components/ui/busy-button';
import {
Dialog,
DialogContent,
@@ -206,6 +207,12 @@ interface ConfirmModalProps {
description?: string;
hint?: React.ReactNode;
confirmLabel: React.ReactNode;
/**
* Progressive confirm copy shown only after delayed visual busy
* (see BusyButton / useVisualBusy). Prefer static confirmLabel + this over
* swapping confirmLabel when confirming flips true.
*/
busyConfirmLabel?: React.ReactNode;
cancelLabel?: React.ReactNode;
confirming?: boolean;
/** When true, the confirm action stays disabled (e.g. plan still loading). */
@@ -225,6 +232,7 @@ export function ConfirmModal({
description,
hint,
confirmLabel,
busyConfirmLabel,
cancelLabel = 'Cancel',
confirming = false,
confirmDisabled = false,
@@ -234,14 +242,17 @@ export function ConfirmModal({
}: ConfirmModalProps) {
const Header = variant === 'destructive' ? ConfirmDestructiveHeader : ConfirmHeader;
const cancelClass = buttonVariants({ variant: 'outline', size: 'sm' });
const actionClass = buttonVariants({
variant: variant === 'destructive' ? 'destructive' : 'default',
size: 'sm',
});
const actionVariant = variant === 'destructive' ? 'destructive' : 'default';
const actionDisabled = confirming || confirmDisabled;
const handleOpenChange = (next: boolean) => {
// Block Esc / overlay dismiss while the confirm action owns the request.
if (!next && confirming) return;
onOpenChange(next);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent className={cn('flex max-h-[85dvh] flex-col p-0 gap-0 overflow-hidden border-card-border/60', SIZE_CLASS[size])}>
<Header kicker={kicker} title={title} description={description} />
{children !== undefined && <ModalBody fill>{children}</ModalBody>}
@@ -257,21 +268,28 @@ export function ConfirmModal({
</AlertDialogCancel>
}
primary={
<AlertDialogAction
className={actionClass}
disabled={actionDisabled}
onClick={(e) => {
const result = onConfirm();
// Async confirms keep the dialog open so the caller can render
// `confirming` state and close via onOpenChange when work completes.
// Sync confirms let Radix auto-close.
if (result instanceof Promise) {
e.preventDefault();
void result;
}
}}
>
{confirmLabel}
// BusyButton never uses asChild; Radix owns asChild on the Action
// shell so the multi-child busy chrome stays on the real button.
<AlertDialogAction asChild disabled={actionDisabled}>
<BusyButton
variant={actionVariant}
size="sm"
pending={confirming}
busyLabel={busyConfirmLabel}
disabled={actionDisabled}
onClick={(e) => {
const result = onConfirm();
// Async confirms keep the dialog open so the caller can render
// `confirming` state and close via onOpenChange when work completes.
// Sync confirms let Radix auto-close.
if (result instanceof Promise) {
e.preventDefault();
void result;
}
}}
>
{confirmLabel}
</BusyButton>
</AlertDialogAction>
}
/>
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { DURATION_BASE_MS, useVisualBusy } from '../useVisualBusy';
describe('useVisualBusy', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('does not show busy while not pending', () => {
const { result } = renderHook(() => useVisualBusy(false));
expect(result.current.locked).toBe(false);
expect(result.current.showBusy).toBe(false);
});
it('never shows busy if pending ends before the delay', () => {
const { result, rerender } = renderHook(
({ pending }) => useVisualBusy(pending),
{ initialProps: { pending: true } },
);
expect(result.current.locked).toBe(true);
expect(result.current.showBusy).toBe(false);
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS - 1);
});
expect(result.current.showBusy).toBe(false);
rerender({ pending: false });
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS);
});
expect(result.current.locked).toBe(false);
expect(result.current.showBusy).toBe(false);
});
it('shows busy after continuous pending through the delay', () => {
const { result } = renderHook(() => useVisualBusy(true));
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS);
});
expect(result.current.locked).toBe(true);
expect(result.current.showBusy).toBe(true);
});
it('clears showBusy when pending becomes false', () => {
const { result, rerender } = renderHook(
({ pending }) => useVisualBusy(pending),
{ initialProps: { pending: true } },
);
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS);
});
expect(result.current.showBusy).toBe(true);
rerender({ pending: false });
expect(result.current.showBusy).toBe(false);
});
it('does not update state after unmount mid-delay', () => {
const { unmount } = renderHook(() => useVisualBusy(true));
unmount();
expect(() => {
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS);
});
}).not.toThrow();
});
it('restarts the delay on rapid pending toggles', () => {
const { result, rerender } = renderHook(
({ pending }) => useVisualBusy(pending),
{ initialProps: { pending: true } },
);
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS - 50);
});
expect(result.current.showBusy).toBe(false);
rerender({ pending: false });
rerender({ pending: true });
act(() => {
vi.advanceTimersByTime(DURATION_BASE_MS - 50);
});
expect(result.current.showBusy).toBe(false);
act(() => {
vi.advanceTimersByTime(50);
});
expect(result.current.showBusy).toBe(true);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
/**
* Keep in sync with `--duration-base` in `frontend/src/index.css`.
* Default gate before showing delayed busy chrome (spinner / progressive label).
*/
export const DURATION_BASE_MS = 220;
/**
* Split interaction lock from delayed visual busy so fast ops never flash chrome.
*
* - `locked` tracks the raw `pending` flag (caller uses this to disable controls).
* - `showBusy` becomes true only after `pending` stays true for `delayMs`.
*/
export function useVisualBusy(
pending: boolean,
delayMs: number = DURATION_BASE_MS,
): { locked: boolean; showBusy: boolean } {
const [showBusy, setShowBusy] = useState(false);
useEffect(() => {
if (!pending) {
setShowBusy(false);
return;
}
setShowBusy(false);
const id = window.setTimeout(() => {
setShowBusy(true);
}, delayMs);
return () => {
window.clearTimeout(id);
};
}, [pending, delayMs]);
return { locked: pending, showBusy };
}