From 61e79861272e305d14fbbeecfa4de2eeea4f2f32 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sat, 23 May 2026 02:28:10 -0400 Subject: [PATCH] fix(stacks): close double-click race in create handler (F-2) The setState-based busy guard could race a second click that landed before React committed the disabled state, so two POSTs got dispatched when a user double-clicked Create. The new useRef-based check is read and written synchronously inside the handler so a re-entrant invocation bails before issuing a second fetch. The accompanying E2E (`create dialog: double-clicking Create fires only one POST`) was also hanging to its 30s test timeout: the second click's locator resolution could outlive the dialog when the first POST resolved quickly, so Playwright waited for `[role="dialog"]` to reappear. Both clicks now fire in the same microtask via Promise.all with a 1s timeout on the second click so the locator-resolution path fails fast instead of hanging the test. --- e2e/stacks.spec.ts | 12 +++++++++--- .../components/EditorLayout/CreateStackDialog.tsx | 11 ++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) 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();