Press Enter while typing Korean and the message sends twice
A chat input seems simple. Press Enter to send; press Shift+Enter for a line break. It takes ten lines to implement.
Then a Korean user tries it, and it breaks.
What happens
Hangul is built through composition: press ㅎ + ㅏ + ㄴ to produce 한. The individual consonant and vowel components that combine into a Hangul syllable are called jamo. While composition is in progress, the browser displays a character that has not yet been finalized.
Enter is one of the keys that finalizes that composition.
So when someone types "안녕하세요" and presses Enter, this can happen:
- They press Enter to finalize the composition of the final character,
요 - The handler interprets that Enter as the send command
- The message is sent
- Thinking, "Now I should actually send it," they press Enter again
- An empty message is sent, or the message that was already sent goes out truncated
You will never see this when developing and testing in English. English has no composition stage, so Enter is always just Enter.
The standard solution, and where it fails
Browsers provide the isComposing flag for this case. It is true while composition is underway.
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.isComposing) send();
});
That should be the end of it. In Chrome and Firefox, it is.
It is not the end in Safari.
The reason is event order.
| Browser | Order |
|---|---|
| Chrome, Firefox | keydown (isComposing = true) → compositionend |
| Safari | compositionend → keydown (isComposing = false) |
Safari fires the end of composition before keydown. By the time the keydown arrives, composition has already ended, so isComposing is false. The guard lets it through, and the message sends.
This is an old WebKit bug with a number: WebKit #165004.
A guard that actually works
Do not leave the flag entirely to the browser. Track it yourself.
let composing = false;
input.addEventListener("compositionstart", () => { composing = true; });
input.addEventListener("compositionend", () => { composing = false; });
input.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
// e.isComposing: Chrome/Firefox
// composing: manual tracking for Safari's event-order issue
// keyCode 229: legacy signal that composition is active, for older engines
if (e.isComposing || composing || e.keyCode === 229) return;
send();
});
I check all three. One is standard, one is manually tracked, and one is legacy. They look redundant, but each covers a different engine.
You might ask whether manually tracking the flag has the same problem in Safari: if compositionend arrives before keydown, it also sets the manual flag to false. This is the subtle part. Do not lower the flag immediately in the compositionend handler. Lower it one tick later. That catches Safari's ordering too.
input.addEventListener("compositionend", () => {
setTimeout(() => { composing = false; }, 0);
});
This is a common workaround when browser event order cannot be trusted. It is not elegant, but it works.
Why this is not a minor bug
This was reported to Cursor before. The wording of the title was exact: one bug breaks input for all CJK users. Korean, Chinese, and Japanese all use composition input.
The symptom is invisible to developers. Develop in English, test in English, run QA in English, and it never appears. You only learn about it when a user reports it. That user will say, "Sometimes my messages send twice," not "Safari fires compositionend before keydown."
Checklist
Things I check when building a UI that accepts Hangul input:
- Every input with Enter-to-send. Chat, comments, search, tag fields, command palettes.
- Test in Safari for real. It passes in Chrome alone.
- Other shortcuts during composition. Enter is not the only issue. Press Escape while typing Korean in a modal that closes on Escape, and it may close the modal instead of canceling composition.
- Character limits. Counting a character in composition makes the count jump through
ㅎ,하, and한before it becomes한. - Autocomplete and debounce. Sending API requests for intermediate composition states means searching for
ㅎ.
The question that stays
There is something interesting about the nature of this bug. Code that reads the specification correctly and uses the standard API correctly is still wrong. isComposing was made for exactly this purpose; using it is not the mistake. One engine simply emits the events in a different order.
That means this class of bug cannot be prevented by reading documentation more carefully. It only appears when you type in that language in that actual environment.
The question to ask is this: Have I ever actually typed in Korean in my tests? If an automated test sets input.value = "안녕", it has never triggered a composition event.