From 3f23347d10227a0de3578b9f7725767a092c8584 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 02:24:27 +0800 Subject: [PATCH] fix(host): the native flow discards chooser settlements after unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An HMR replacement left the old instance's pick promise able to adopt a second path through the shared owner callbacks; settlements now check a component-lifetime ref (an injected-face identity change alone keeps the pending settlement — the host-side dialog is unchanged, and the wire has no per-request abort). Both settlement arms covered (ds-review-bot). --- .../src/client/index.ts | 19 +++++++++++++-- .../tests/client-flow.spec.tsx | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) 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()