From 6add9a8eef7ea8937d4a2253be8ac346a3a0d490 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 23:37:07 +0800 Subject: [PATCH] fix(host): harden the browser dialog against review round-2 races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dismissal (Escape/mask) is ignored while adoption is busy: the owner's in-flight createWorkspace must not land behind an apparent cancel. - Every parent control goes inert while the nested create dialog is open (Modal traps no focus, so Shift-Tab/AT could close, adopt, or retarget underneath the child). - Creation settlements are gated on an open-generation ref: a create that resolves or rejects after the flow closed (and possibly reopened) can no longer relist the stale target or surface its alert in the fresh dialog. - The keyless snapshot waits for the Open button's enabled state before clicking — on slow runners the selection's child listing was still in flight and the click landed on a disabled button. --- apps/web/tests/workspace-flow.snapshot.ts | 5 ++ .../src/client/DirectoryBrowser.tsx | 36 +++++++---- .../tests/directory-browser.spec.tsx | 59 +++++++++++++++++++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 8d3316ab0a..86a20e73fe 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -202,6 +202,11 @@ it('adopts a directory through the composed in-app browse flow and lands in its // row's name span is stable (clicks bubble to the row button). fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 })) fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) + // Open disables while the selection's child listing is in flight; wait for + // the enabled state or the click lands on a dead button on slow runners. + await waitFor(() => { + expect(within(dialog).getByRole('button', { name: '打开' }).disabled).toBe(false) + }, { timeout: 10_000 }) fireEvent.click(within(dialog).getByRole('button', { name: '打开' })) await findHeroComposer() await waitFor(() => { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index a3bbc576a7..ec66c6e994 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -112,6 +112,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [creatingFolder, setCreatingFolder] = useState(false) const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) + // Bumped on every open/close edge: settlements from a previous open (a + // pending creation included) must never mutate a reopened dialog. + const openGeneration = useRef(0) // Deep ancestry overflows the trail; keep its tail (the current directory // and the edit zone beside it) in view whenever the chain changes. const crumbTrailRef = useRef(null) @@ -164,10 +167,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Every open starts fresh at the Host home directory; closing invalidates // any in-flight response so a late arrival cannot repopulate a closed dialog. useEffect(() => { + openGeneration.current += 1 if (open) { setParent(null) setSelected(null) setChild(null) + setCreatingFolder(false) navigate() return } @@ -190,7 +195,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, if (name === '') return setCreatingFolder(true) setCreateError(null) + const generation = openGeneration.current createDirectory(targetPath, name).then((createdPath) => { + // A settlement from a closed (possibly reopened) flow must not touch + // the fresh dialog or issue a relist against the stale target. + if (generation !== openGeneration.current) return setCreatingFolder(false) setFolderDraft(null) // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the @@ -210,6 +219,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(failureText(reason)) }) }, (reason: unknown) => { + if (generation !== openGeneration.current) return setCreatingFolder(false) setCreateError(failureText(reason)) }) @@ -226,14 +236,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, if (!open) return null const twoPane = selected !== null + // The nested create dialog owns the interaction while open: Modal has no + // focus trap, so every parent control goes inert (Shift-Tab or AT must not + // close, adopt, or retarget underneath the child). + const parentInert = busy || folderDraft !== null return ( { if (folderDraft === null) onClose() }} + // the nested create dialog is up only that topmost dialog may close + // (its own guard keeps an in-flight creation open), and an in-flight + // adoption pins the flow — dismissing it would leave the owner's + // createWorkspace to land after an apparent cancel. + onClose={() => { if (folderDraft === null && !busy) onClose() }} title={t('browser.title')} className={clsx(css.dialog)} headless @@ -251,7 +267,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, - +