From 0b9dd747e7304db4ba26d4015fb6505f5f521263 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 2 Nov 2026 22:56:57 +0545 Subject: [PATCH 1/3] fix(prompt-input): leave a composition-ending Escape to the IME While the suggestion list is open, the window-capture claimEscape listener marks Escape handled so an enclosing Radix layer does not dismiss. It skipped only `isComposing`, but Safari sends the key that ends a composition with `isComposing` already false and keyCode 229. Skip that too, as onKeyDown and the toolbar's useClaimEscape already do. Follows up the review of #150 (corbitsdev/react-ui#142 finding 13). --- src/hooks/use-prompt-input.test.ts | 10 ++++++++++ src/hooks/use-prompt-input.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/hooks/use-prompt-input.test.ts b/src/hooks/use-prompt-input.test.ts index 51dfb4db..bec64b1b 100644 --- a/src/hooks/use-prompt-input.test.ts +++ b/src/hooks/use-prompt-input.test.ts @@ -304,6 +304,16 @@ describe("usePromptInput", () => { } }); + test("Safari's composition-ending Escape (keyCode 229) is left to the IME", () => { + const view = mountPrompt(); + view.type("@a"); + expect(view.press("Escape", { isComposing: false, keyCode: 229 })).toBe( + true, + ); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); + view.unmount(); + }); + test("Enter with no list submits and keeps the newline out", () => { const view = mountPrompt(); view.type("hello"); diff --git a/src/hooks/use-prompt-input.ts b/src/hooks/use-prompt-input.ts index 10039ef7..548eefa6 100644 --- a/src/hooks/use-prompt-input.ts +++ b/src/hooks/use-prompt-input.ts @@ -164,7 +164,10 @@ export function usePromptInput({ if ( event.key === "Escape" && event.target === element && - !event.isComposing + // 229 is what Safari reports for the key that ends a composition, + // after `isComposing` has already turned false. + !event.isComposing && + event.keyCode !== 229 ) { event.preventDefault(); } From 4a107d6ae82aa88abffa8fb4eb9541d66319712b Mon Sep 17 00:00:00 2001 From: "user.email" Date: Tue, 3 Nov 2026 23:14:13 -0800 Subject: [PATCH 2/3] fix(prompt-input): stop composition Escape before the dialog A keyCode 229 Escape was left unclaimed so the IME could take it. Radix still saw it on document capture, preventDefaulted it, and dismissed the dialog. Stop that event at the window instead, and keep preventDefault on a normal Escape so the list closes and the dialog stays. --- src/hooks/use-prompt-input.test.ts | 25 +++++++++++++++++++------ src/hooks/use-prompt-input.ts | 21 ++++++++++----------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/hooks/use-prompt-input.test.ts b/src/hooks/use-prompt-input.test.ts index bec64b1b..d0fc3997 100644 --- a/src/hooks/use-prompt-input.test.ts +++ b/src/hooks/use-prompt-input.test.ts @@ -305,13 +305,26 @@ describe("usePromptInput", () => { }); test("Safari's composition-ending Escape (keyCode 229) is left to the IME", () => { + const seen: boolean[] = []; + const layer = (event: KeyboardEvent) => { + if (event.key === "Escape") seen.push(event.defaultPrevented); + }; + document.addEventListener("keydown", layer, { capture: true }); const view = mountPrompt(); - view.type("@a"); - expect(view.press("Escape", { isComposing: false, keyCode: 229 })).toBe( - true, - ); - expect(view.prompt.activeSuggestion?.id).toBe("ada"); - view.unmount(); + try { + view.type("@a"); + expect(view.press("Escape", { isComposing: false, keyCode: 229 })).toBe( + true, + ); + expect(seen).toEqual([]); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); + view.press("Escape"); + expect(view.prompt.suggestions).toEqual([]); + expect(seen).toEqual([true]); + } finally { + view.unmount(); + document.removeEventListener("keydown", layer, { capture: true }); + } }); test("Enter with no list submits and keeps the newline out", () => { diff --git a/src/hooks/use-prompt-input.ts b/src/hooks/use-prompt-input.ts index 548eefa6..19b23519 100644 --- a/src/hooks/use-prompt-input.ts +++ b/src/hooks/use-prompt-input.ts @@ -153,24 +153,23 @@ export function usePromptInput({ // A Radix dialog or popover around the prompt dismisses on an Escape it // sees in the document's capture phase, before React's handlers run, // unless the event is already marked handled. While the list is open, mark - // it first from the window's capture phase; `onKeyDown` still closes the - // list, so propagation is left alone. + // a normal Escape from the window's capture phase and let it propagate so + // `onKeyDown` can close the list. Stop the composition-ending key instead, + // without preventDefault, so the dialog never sees it. useEffect(() => { const element = textareaRef.current; if (!listOpen || element === null) return; const view = element.ownerDocument.defaultView; if (view === null) return; const claimEscape = (event: KeyboardEvent) => { - if ( - event.key === "Escape" && - event.target === element && - // 229 is what Safari reports for the key that ends a composition, - // after `isComposing` has already turned false. - !event.isComposing && - event.keyCode !== 229 - ) { - event.preventDefault(); + if (event.key !== "Escape" || event.target !== element) return; + // 229 is what Safari reports for the key that ends a composition, + // after `isComposing` has already turned false. + if (event.keyCode === 229) { + event.stopPropagation(); + return; } + if (!event.isComposing) event.preventDefault(); }; view.addEventListener("keydown", claimEscape, { capture: true }); return () => From be45a5dda1cde37b8793f94c60f642e808c7007b Mon Sep 17 00:00:00 2001 From: "user.email" Date: Tue, 3 Nov 2026 23:18:54 -0800 Subject: [PATCH 3/3] fix(prompt-input): stop a composing Escape before the dialog An Escape with isComposing still reached document capture, so Radix preventDefaulted it and dismissed the dialog. Stop that key the same way as keyCode 229, and keep claiming a normal Escape. --- src/hooks/use-prompt-input.test.ts | 8 ++++++-- src/hooks/use-prompt-input.ts | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/hooks/use-prompt-input.test.ts b/src/hooks/use-prompt-input.test.ts index d0fc3997..dd3b62cc 100644 --- a/src/hooks/use-prompt-input.test.ts +++ b/src/hooks/use-prompt-input.test.ts @@ -293,11 +293,15 @@ describe("usePromptInput", () => { const view = mountPrompt(); try { view.type("@a"); - view.press("Escape", { isComposing: true }); + expect(view.press("Escape", { isComposing: true, keyCode: 27 })).toBe( + true, + ); + expect(seen).toEqual([]); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); view.press("Escape"); expect(view.prompt.suggestions).toEqual([]); view.press("Escape"); - expect(seen).toEqual([false, true, false]); + expect(seen).toEqual([true, false]); } finally { view.unmount(); document.removeEventListener("keydown", layer, { capture: true }); diff --git a/src/hooks/use-prompt-input.ts b/src/hooks/use-prompt-input.ts index 19b23519..66c7e964 100644 --- a/src/hooks/use-prompt-input.ts +++ b/src/hooks/use-prompt-input.ts @@ -154,8 +154,8 @@ export function usePromptInput({ // sees in the document's capture phase, before React's handlers run, // unless the event is already marked handled. While the list is open, mark // a normal Escape from the window's capture phase and let it propagate so - // `onKeyDown` can close the list. Stop the composition-ending key instead, - // without preventDefault, so the dialog never sees it. + // `onKeyDown` can close the list. Stop a composing Escape instead, without + // preventDefault, so the dialog never sees it. useEffect(() => { const element = textareaRef.current; if (!listOpen || element === null) return; @@ -165,11 +165,11 @@ export function usePromptInput({ if (event.key !== "Escape" || event.target !== element) return; // 229 is what Safari reports for the key that ends a composition, // after `isComposing` has already turned false. - if (event.keyCode === 229) { + if (event.isComposing || event.keyCode === 229) { event.stopPropagation(); return; } - if (!event.isComposing) event.preventDefault(); + event.preventDefault(); }; view.addEventListener("keydown", claimEscape, { capture: true }); return () =>