diff --git a/e2e/stacks.spec.ts b/e2e/stacks.spec.ts index c1d29e75..d6d9ba7c 100644 --- a/e2e/stacks.spec.ts +++ b/e2e/stacks.spec.ts @@ -72,9 +72,15 @@ test.describe('Stack management', () => { await page.locator('#create-stack-name').fill(stackName); const createBtn = page.locator('[role="dialog"]').getByRole('button', { name: /^Create/ }); - // Rapid double-click; the busy guard must reject the second click synchronously. - await createBtn.click(); - await createBtn.click({ force: true }).catch(() => { /* second click may land on disabled btn */ }); + // Fire both clicks in the same microtask via Promise.all so the second + // dispatches without an awaited gap that would let React commit the + // disabled state in between. The 1s timeout on the second click prevents + // a 30s hang on locator resolution if the first POST has already closed + // the dialog. + await Promise.all([ + createBtn.click(), + createBtn.click({ force: true, timeout: 1_000 }).catch(() => undefined), + ]); await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 }); await expect(page.getByText(`Stack "${stackName}" created.`)).toBeVisible({ timeout: 5_000 }); diff --git a/frontend/src/components/EditorLayout/CreateStackDialog.tsx b/frontend/src/components/EditorLayout/CreateStackDialog.tsx index 4ef24bd7..4a0e4df5 100644 --- a/frontend/src/components/EditorLayout/CreateStackDialog.tsx +++ b/frontend/src/components/EditorLayout/CreateStackDialog.tsx @@ -37,6 +37,12 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks const { activeNode } = useNodes(); const [createMode, setCreateMode] = useState('empty'); const [newStackName, setNewStackName] = useState(''); + // Synchronous guard. The disabled-button + setState pair can race a rapid + // second click that lands before React has committed the disabled state, + // re-entering the handler with a stale closure value of creatingEmpty and + // firing a second POST. The ref check is read/written synchronously inside + // the handler so the second invocation bails before issuing another POST. + const creatingEmptyRef = useRef(false); const [creatingEmpty, setCreatingEmpty] = useState(false); const [dockerRunInput, setDockerRunInput] = useState(''); const [convertedYaml, setConvertedYaml] = useState(null); @@ -72,10 +78,11 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks }; const handleCreateStack = async () => { - if (creatingEmpty) return; + if (creatingEmptyRef.current) return; if (!newStackName.trim()) return; const stackName = newStackName.trim(); const sourceNodeId = activeNode?.id; + creatingEmptyRef.current = true; setCreatingEmpty(true); try { const response = await apiFetch('/stacks', { @@ -104,6 +111,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks console.error('Failed to create stack:', error); toast.error((error as Error).message || 'Failed to create stack.'); } finally { + creatingEmptyRef.current = false; setCreatingEmpty(false); } }; @@ -300,6 +308,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks onOpenChange(o); if (!o) { setCreateMode('empty'); + creatingEmptyRef.current = false; setCreatingEmpty(false); resetCreateFromGitForm(); resetCreateFromDockerRunForm();