fix(web): prevent Enter from sending message during IME composition on Safari#1815
fix(web): prevent Enter from sending message during IME composition on Safari#1815qianqiuqiu wants to merge 3 commits intoMoonshotAI:mainfrom
Conversation
…n Safari Safari fires compositionend before the Enter keydown (WebKit bug #165004), making both isComposing and nativeEvent.isComposing false by the time the handler checks. Add a 100 ms post-compositionend cooldown via useRef to block premature submission. Chrome/Firefox behavior is unaffected.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bf60a7f602
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ( | ||
| isComposing || | ||
| e.nativeEvent.isComposing || | ||
| Date.now() - compositionEndTimeRef.current < 100 | ||
| ) { |
There was a problem hiding this comment.
Gate composition cooldown so fast Enter still submits
The new Date.now() - compositionEndTimeRef.current < 100 guard in handleKeyDown runs on all browsers, not just Safari. After any compositionend (for example, IME text committed via Space/candidate click), a user who presses Enter quickly to send will hit this early return before e.preventDefault(), so the textarea inserts a newline instead of submitting. This creates a cross-browser regression for fast IME workflows; the cooldown should be limited to the Safari-specific path (or suppress default Enter behavior when blocking submit).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Fixes a Safari/macOS IME edge case where pressing Enter to commit IME text can incorrectly submit the chat message due to WebKit’s compositionend/keydown event ordering.
Changes:
- Add a
compositionendtimestamp ref and an Enter “cooldown” guard to prevent unintended submission on Safari. - Update
onCompositionEndto record the last composition end time. - Add an Unreleased changelog entry documenting the Safari IME fix.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| web/src/components/ai-elements/prompt-input.tsx | Adds a post-compositionend guard to block Enter submission immediately after composition ends (Safari workaround). |
| CHANGELOG.md | Documents the web IME/Safari Enter submission fix in Unreleased. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| onBlur={() => setIsComposing(false)} | ||
| onCompositionEnd={() => setIsComposing(false)} | ||
| onCompositionEnd={() => { | ||
| setIsComposing(false); | ||
| compositionEndTimeRef.current = Date.now(); | ||
| }} |
There was a problem hiding this comment.
onBlur/onCompositionEnd are defined here but then can be overridden by {...props} later in the JSX spread. In current usage (ChatPromptComposer) onBlur is passed, so setIsComposing(false) never runs on blur; similarly, any future onCompositionEnd prop would bypass updating compositionEndTimeRef and reintroduce the Safari bug. Consider destructuring onBlur/onCompositionStart/onCompositionEnd from props and invoking them inside internal handlers (or spread props before attaching these internal handlers) so internal composition state and the cooldown ref are always maintained.
| if ( | ||
| isComposing || | ||
| e.nativeEvent.isComposing || | ||
| Date.now() - compositionEndTimeRef.current < 100 | ||
| ) { |
There was a problem hiding this comment.
The cooldown uses Date.now() deltas, which are based on handler execution time and wall-clock time. If the main thread is blocked (or the system clock changes), the computed delta can exceed 100ms (or go negative), defeating the safeguard or suppressing Enter unexpectedly. Consider storing the compositionend event timestamp (e.g., e.timeStamp or performance.now()) and comparing it to the keydown event’s timestamp instead of Date.now().
…cooldown Addresses Copilot review feedback — event.timeStamp is monotonic and immune to main-thread blocking or system clock changes, making the 100 ms composition cooldown more reliable than Date.now() deltas.
The arrow function referenced e.timeStamp but had no parameter, which would throw ReferenceError at runtime.
Related Issue
N/A (new issue — discovered during manual testing on Safari + macOS native Chinese IME)
Description
Bug
On Safari (macOS), pressing Enter to commit raw English letters from the IME candidate bar sends the message immediately instead of committing the text to the input field. Chrome and Firefox are not affected.
Repro steps
okorhello— the IME candidate bar appears with the raw lettersExpected: The English letters (
ok/hello) are committed to the textarea as-is; the user continues editing.Actual: The message is sent immediately.
Root cause
The existing guard in
PromptInputTextareachecks two conditions before allowing Enter to submit:This is a thoughtful design —
useStatemay have stale values due to React's asynchronous batching, soe.nativeEvent.isComposingis added as a synchronous fallback. This works perfectly on Chrome and Firefox, where the W3C-specified event order is:However, Safari has a long-standing event ordering bug (WebKit #165004, status: NEW, unfixed) that reverses the order:
Because
compositionendfires before the Enterkeydown:isComposing(React state) → already set tofalseby theonCompositionEndhandlere.nativeEvent.isComposing→ alsofalse(the browser considers composition ended)Both guards fail, and the Enter keystroke reaches
form?.requestSubmit().Fix
Record the timestamp of
compositionendvia auseRef. In the Enter keydown handler, treat any Enter arriving within 100 ms ofcompositionendas part of the composition — not a send action.Uses
event.timeStamp(monotonic, immune to main-thread blocking) instead ofDate.now()for reliable timing — adopted from Copilot review feedback.This is the same approach used by ProseMirror and CodeMirror to handle this Safari quirk. The 100 ms window is far shorter than any realistic gap between finishing a composition and intentionally pressing Enter to send, so Chrome/Firefox behavior is completely unaffected.
Scope: 1 file changed, 3 lines of logic added. No behavioral change on Chrome/Firefox.
Review feedback adopted
Date.now()toevent.timeStampfor monotonic, main-thread-safe timing — thanks for catching this.eparameter inonCompositionEndcallback that would have caused aReferenceErrorat runtime — good catch, especially since this file is excluded fromtscchecking.Checklist
make gen-changelogto update the changelog.make gen-docsto update the user documentation. (N/A)