field note / IME composition / 2026-07-04
An input-boundary failure: the Enter that commits a text composition is the same Enter your handler reads as submit. So a form fires while the user is still choosing a word — send runs on half-typed text. It is universal to every input that treats Enter as submit or select. Japanese, Chinese and Korean input is just where it fires first and hardest, because those users press Enter mid-word all day.
A user in Tokyo starts typing a message. They get a few kana in, the IME shows a list of kanji candidates, and they press Enter to pick the one they want. The form submits. The half-finished message is gone — sent to whoever was on the other end — and the box is empty. They never pressed send. They pressed Enter to choose a word, and the same Enter your submit handler was listening for.
This is the single most common way foreign software breaks for a Japanese user, and it is not a Japanese bug. It is an input-boundary bug. Any text field that treats Enter as "submit" or "select the highlighted item" owns it. The reason it surfaces in CJK first is mechanical: to type Japanese, Chinese or Korean you compose — you type phonetic input and press Enter to commit a conversion — so those users hit Enter mid-input constantly. An English user only presses Enter when they mean it. Same code, same keypress; one language routes a "commit" through the key you read as "done."
The fix is famous. Every writeup ends the same way: check isComposing and bail out. It is also the largest failure family in a corpus of 97 real ones I keep — 38 of 97, three times the size of the next. A one-line fix that everyone knows, and the bug is still everywhere. That contradiction is the whole story. The four things below are why the one-liner does not stick.
"Check isComposing" is not one guard. It is four, and a team that learns one of them ships the other three broken. Here is the same guard, per platform, taken from real fixes in the corpus:
// Vanilla DOM / Svelte — the native KeyboardEvent carries the flag directly.
input.addEventListener('keydown', (e) => {
if (e.isComposing) return; // chakra-ui/zag#3198 (merged)
if (e.key === 'Enter') submit();
});
// React — the SYNTHETIC event has no isComposing. Read the native one.
function onKeyDown(e) {
if (e.nativeEvent.isComposing) return; // mrdjohnson/llm-x#53, twentyhq/twenty#22270 (merged)
if (e.key === 'Enter') send();
}
// Safari / WebKit — reports the commit keydown as key="Enter" WITH isComposing=true,
// so you must bail on isComposing before preventDefault, or WebKit eats the commit.
function onKeyDownWebKit(e) {
if (e.nativeEvent.isComposing) return; // react-component/mentions#325 (Safari-only)
// Chromium reports keyCode 229 here and never even enters this branch.
}
// Belt-and-suspenders — some engines only expose keyCode 229,
// and some fire key === 'Process' instead of exposing isComposing.
function onKeyDownRobust(e) {
if (e.isComposing || e.keyCode === 229 || e.key === 'Process') return; // misskey-dev/misskey#17646 (merged)
if (e.key === 'Enter') act();
}
A React developer who "knows the fix" writes if (e.isComposing) return, reads undefined off the synthetic event, guards nothing, and ships. A Chrome-only team never meets the Safari variant, because Chromium routes the commit through keyCode 229 and their Enter branch is never reached. None of this is carelessness. The correct guard depends on the framework's event model and the browser engine, and no single line is right in all four columns.
The reason this survives good code review is that the web platform gives you no clean signal for "this is the keypress that commits the composition." Two open standards issues, both in the corpus, say so directly:
w3c/uievents#202 — the spec orders the input event before compositionend; Chrome and Safari follow it, Firefox and Edge fire input after compositionend. Any guard built on event ordering behaves differently per browser.w3c/input-events#176 — every input event during a composition, including the one that commits it, carries isComposing = true and inputType = "insertCompositionText". No property isolates the commit. You cannot detect "the composition just finished" from the input event alone; you also have to listen for compositionend.That is the real story for a platform, framework or foundation-model team: the correct fix is a coordination between three events — compositionstart, keydown, compositionend — that the spec under-defines, so every component re-derives it and a fraction re-derive it wrong. The one-liner is the symptom. The missing platform primitive is the disease.
The corpus shows the bug rarely appears in a codebase that has never heard of it. It appears in codebases that fixed the main composer and left every sibling input undefended. The chat box guards isComposing; the inline rename next to it does not. Real examples, grouped by the input that was missed:
siyuan-note/siyuan#17988 (merged), CherryHQ/cherry-studio#16582, langflow-ai/langflow#13887, janhq/jan#8359.langgenius/dify#38119, danny-avila/LibreChat#13996, tusen-ai/naive-ui#8115 (merged).payloadcms/payload#17138 (merged), deta/surf#184, excalidraw/excalidraw#11573.If you are auditing your own app, do not stop at the chat box. Grep every key === 'Enter' and every keyCode === 13 handler, and confirm each one has the guard. The commit keypress reaches all of them, not just the one you already fixed.
Three corpus entries put the bug outside the browser entirely, which is why this is filed under input boundaries and not web:
dotnet/maui#36179 — on Windows, Entry.Completed fires when the user presses Enter inside the IME candidate window, so completion runs before the conversion commits. Same bug, XAML instead of the DOM.warpdotdev/warp#320 — the terminal doesn't render IME marked (preedit) text at all, so the user composes blind. The input boundary isn't just mishandled here, it's invisible.zed-industries/zed#59193 — text jumps vertically while composing with a Chinese IME on Windows; the marked-text region isn't held on a stable baseline.Any product with a text field owns this, whether the field is HTML, XAML, or a raw terminal grid.
You cannot catch this by typing ASCII — an English keypress never sets isComposing. The test has to synthesize the composition lifecycle. This harness drives a DOM input through compositionstart → keydown(Enter, isComposing:true) → compositionend and asserts the submit handler never fired on the commit key:
// vitest / jsdom. The point is the isComposing:true keydown between start and end.
import { fireEvent } from '@testing-library/dom';
function typeAndCommit(input) {
fireEvent.compositionStart(input);
fireEvent.compositionUpdate(input, { data: 'にほんご' });
// The commit keypress. WebKit-shaped: Enter carrying isComposing.
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229, isComposing: true });
fireEvent.compositionEnd(input, { data: '日本語' });
}
test('the Enter that commits an IME composition does not submit', () => {
const onSubmit = vi.fn();
const input = mountInputWithSubmit(onSubmit);
typeAndCommit(input);
expect(onSubmit).not.toHaveBeenCalled(); // must stay quiet on the commit Enter
// And a real, post-composition Enter still submits:
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, isComposing: false });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
The second assertion matters as much as the first. The guard has to let the next Enter through, or you have traded a false submit for a dead submit button — which is how a careless fix regresses into a second bug report a week later.
The one-line habit is worth stating plainly, because the whole failure is a mismatch between two questions that feel identical and aren't:
| the key you saw | what the user meant | the signal that separates them |
|---|---|---|
| Enter, ASCII typing | submit / select | isComposing is false — act |
| Enter, mid-composition | commit a candidate | isComposing true, or keyCode 229, or key === 'Process' — skip |
| Enter, React synthetic | either | flag lives on e.nativeEvent, not e |
So the check is: before any Enter handler acts, ask whether a composition is active, and ask it against the object that actually carries the flag on your stack. If you are in React, that object is nativeEvent. If you support Safari, bail before preventDefault. If you want to be safe across engines, accept keyCode 229 and key === 'Process' as composition too. Then write the test above, because ASCII will never show you this — you have to hand the handler the keypress it is quietly afraid of.
This is one family in a corpus of 97 real CJK, IME and Unicode failures I have been collecting, most of them one-line fixes hiding in software that works perfectly in English. This family is the biggest — 38 entries. Below, unlike the short version of this post, I've pulled a few out of the corpus in full so you can watch the same keypress break three frameworks and two rendering engines, and check every diagnosis against a diff.
+ The same keypress, four more frameworks
The guard matrix above is abstract until you see it fail in real code. These four all sit in the same corpus category — IME composition — and they are deliberately spread across the event models: a Vue composer, a React chat, a React component that only breaks in Safari, and a select-box whose symptom isn't submit at all but picking the wrong item. Two merged, one open, one closed. I keep the closed one in the record because the diagnosis stands whether or not the maintainer took the diff — and because it is the clearest proof that the guard depends on the engine, not the developer.
onKeydown called send() with no composition check, so confirming a Japanese conversion counted as submit.isComposing || key === 'Process' || keyCode === 229, covering every engine's shape of the commit key.isComposing does not exist, so the guard silently read undefined and did nothing.e.nativeEvent.isComposing. React's synthetic KeyboardEvent doesn't forward the flag; the underlying DOM event does.key === 'Enter' with isComposing:true.keyCode 229, so the Enter branch never runs and the bug is Safari-exclusive.nativeEvent.isComposing before acting. Closed upstream, kept here for the record — the engine-split diagnosis holds regardless.InputPicker treats Enter as "choose the highlighted option," so the commit Enter didn't submit a form — it picked a dropdown item.Read them in a row and the family resolves: one keypress carries two meanings, and the code read the wrong one. misskey read commit as submit; llm-x checked the object that doesn't carry the flag; rc-mentions was correct everywhere except the one engine that shapes the key differently; rsuite read commit as select. Same under-specified boundary — the platform never told any of them which Enter this was — wearing four different costumes across Vue, React, WebKit and a select widget. Grouping by the broken assumption instead of the symptom is what tells you the next place it hides: not the chat box you already fixed, but the field beside it.
→ Read next / verify
Don't take my word for the diagnosis — the misskey diff is public, read it and decide if the guard holds.
— greymoth (@greymoth__)