fix(host,client): default-export the picker seam; invalidate stale kind reads

ds-review-bot round 3. The seam package broke the service-package export
contract (named export only), so the config catalog filed it under Other
libraries and default imports failed; it now default-exports DirectoryPicker
like every abstract seam, and the regenerated catalog lists it as one.

The picker-kind effect also let a settlement from a superseded flow open
leak into the current one (close/reopen mid-describe, or a reconnect that
swaps the backend): the read now resets the affordance on every open and a
cleanup-toggled flag discards obsolete settlements, both directions pinned
by jsdom races.
This commit is contained in:
creatixchu
2026-07-28 17:56:03 +08:00
parent cd7aa3c7d8
commit c4bf919895
5 changed files with 60 additions and 6 deletions

View File

@@ -80,12 +80,18 @@ export function WorkspaceCreateFlow({
const [dialogPicker, setDialogPicker] = useState(false)
useEffect(() => {
if (!open) return
// Reset before each read: a reconnect can change the composed backend, so
// a previous open's answer must not leak into this one; and a settlement
// from a superseded open (flow closed, or a newer read started) is
// discarded via the cleanup-toggled flag.
setDialogPicker(false)
let stale = false
void directoryPickerKind()
.then((kind) => { setDialogPicker(kind === 'dialog') })
.then((kind) => { if (!stale) setDialogPicker(kind === 'dialog') })
// A failed describe hides the entry too: the same Host that cannot
// answer describe cannot serve pickDirectory. (Post-unmount settlement
// is safe: React 18 no-ops setState on unmounted components.)
.catch(() => { setDialogPicker(false) })
// answer describe cannot serve pickDirectory.
.catch(() => { if (!stale) setDialogPicker(false) })
return () => { stale = true }
}, [open, directoryPickerKind])
const items: MenuEntry[] = [

View File

@@ -268,4 +268,50 @@ describe('WorkspacePicker', () => {
)
expect(directoryPickerKind).not.toHaveBeenCalled()
})
/** Render the picker with an owner-controlled `open` and a scripted kind read. */
function togglable(directoryPickerKind: () => Promise<string>) {
const anchorRef = anchor()
const props = (open: boolean) => (
<WorkspacePicker
open={open} anchorRef={anchorRef} useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={directoryPickerKind}
/>
)
const view = render(props(true))
return { setOpen: (open: boolean) => { view.rerender(props(open)) } }
}
it('discards a kind settlement from a superseded flow open', async () => {
let resolveFirst!: (kind: string) => void
const first = new Promise<string>((settle) => { resolveFirst = settle })
const directoryPickerKind = vi.fn<() => Promise<string>>()
.mockImplementationOnce(() => first)
.mockImplementation(async () => 'browse')
const t = togglable(directoryPickerKind)
// Close while the first read is in flight, then let it answer 'dialog':
// the settlement is stale and must not leak into the next open.
t.setOpen(false)
await act(async () => { resolveFirst('dialog') })
t.setOpen(true)
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) })
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('discards a stale describe failure after a newer open already answered', async () => {
let rejectFirst!: (reason: Error) => void
const first = new Promise<string>((_settle, reject) => { rejectFirst = reject })
const directoryPickerKind = vi.fn<() => Promise<string>>()
.mockImplementationOnce(() => first)
.mockImplementation(async () => 'dialog')
const t = togglable(directoryPickerKind)
t.setOpen(false)
t.setOpen(true)
await screen.findByRole('menuitem', { name: 'Open local folder…' })
// The superseded read failing late must not hide the freshly shown entry.
await act(async () => { rejectFirst(new Error('late loss')); await first.catch(() => {}) })
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
})
})