diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index c716b97b0c..3ff6a4b3f9 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -35,6 +35,15 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI // latest handlers, not the ones captured when the chooser opened. const outcome = useRef(props) outcome.current = props + // Unmount (HMR replacing the occupant) discards settlements wholesale: the + // dead instance must neither adopt a path nor drive the owner's error + // surface. The wire carries no per-request abort, so the host-side chooser + // survives until answered — its answer just lands nowhere; the replacement + // instance re-arms under the owner's still-open request. An injected-face + // identity change alone (re-registration) keeps the pending settlement: + // the chooser on the host display is still the same dialog. + const alive = useRef(true) + useEffect(() => () => { alive.current = false }, []) useEffect(() => { if (!open) { armed.current = false @@ -43,8 +52,14 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI if (armed.current) return armed.current = true pick().then( - (path) => { if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) }, - (reason: unknown) => { outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) }, + (path) => { + if (!alive.current) return + if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) + }, + (reason: unknown) => { + if (!alive.current) return + outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) + }, ) }, [open, pick]) return null diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index c92b987f20..656aea74df 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -118,6 +118,30 @@ describe('directory-picker-native client half', () => { expect(first.onPicked).not.toHaveBeenCalled() }) + it('discards a settlement that lands after the flow unmounted', async () => { + let resolve!: (path: string | null) => void + const pick = vi.fn(() => new Promise((settle) => { resolve = settle })) + const props = owner() + const view = render() + expect(pick).toHaveBeenCalledOnce() + view.unmount() + // The dead instance must neither adopt nor error; the owner's callbacks + // stay untouched by the orphaned chooser's answer. + await act(async () => { resolve('/tmp/late') }) + expect(props.onPicked).not.toHaveBeenCalled() + expect(props.onCancel).not.toHaveBeenCalled() + expect(props.onError).not.toHaveBeenCalled() + + // The failure arm is discarded the same way. + let reject!: (reason: unknown) => void + const failing = vi.fn(() => new Promise((_settle, rejectPick) => { reject = rejectPick })) + const late = owner() + const failingView = render() + failingView.unmount() + await act(async () => { reject(new Error('too late')) }) + expect(late.onError).not.toHaveBeenCalled() + }) + it('reports null as cancellation and re-arms after the owner withdraws open', async () => { const pick = vi.fn(async () => null as string | null) const props = owner()