greymoth proof, not pitch Writing · Nº 02

field note / IME composition / 2026-07-04

The keypress that submits your form mid-word.

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.

The fix is one line. The line is different four times.

"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.

Why even careful teams get this wrong: the platform can't tell you either

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:

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 re-occurrence pattern: the main box is guarded, the sibling isn't

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:

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.

It is not a browser bug — the same failure lands in native UI and terminals

Three corpus entries put the bug outside the browser entirely, which is why this is filed under input boundaries and not web:

Any product with a text field owns this, whether the field is HTML, XAML, or a raw terminal grid.

Make it fail the build: a composition-aware regression test

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 compositionstartkeydown(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 check, for the next one

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 sawwhat the user meantthe signal that separates them
Enter, ASCII typingsubmit / selectisComposing is false — act
Enter, mid-compositioncommit a candidateisComposing true, or keyCode 229, or key === 'Process' — skip
Enter, React syntheticeitherflag 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.

misskey — send on the commit Enter merged · PR #17646
ShapeThe canonical case, in Vue. The chat composer's onKeydown called send() with no composition check, so confirming a Japanese conversion counted as submit.
BreaksType a few kana, press Enter to pick the kanji, and the in-progress text sends and the box clears. Every reply a Japanese user tries to write leaves early.
FixGuard the belt-and-suspenders way: skip while isComposing || key === 'Process' || keyCode === 229, covering every engine's shape of the commit key.
llm-x — React's missing flag open · PR #53
ShapeThe React trap in one line. Send-on-Enter checked the synthetic event, where isComposing does not exist, so the guard silently read undefined and did nothing.
BreaksThe candidate-confirm Enter sends the half-composed prompt. The developer may have written a guard and still shipped the bug, because they guarded the wrong object.
FixRead e.nativeEvent.isComposing. React's synthetic KeyboardEvent doesn't forward the flag; the underlying DOM event does.
rc-mentions — only wrong in Safari closed · PR #325
ShapeProof the guard depends on the engine. Confirming a CJK @-mention with Enter selects the highlighted mention only in WebKit, because Safari reports the commit keydown as key === 'Enter' with isComposing:true.
BreaksA Chrome-only team can never reproduce it: Chromium routes the same commit through keyCode 229, so the Enter branch never runs and the bug is Safari-exclusive.
FixBail on nativeEvent.isComposing before acting. Closed upstream, kept here for the record — the engine-split diagnosis holds regardless.
rsuite — select, not submit merged · PR #4585
ShapeThe same keypress, a different verb. rsuite's InputPicker treats Enter as "choose the highlighted option," so the commit Enter didn't submit a form — it picked a dropdown item.
BreaksFilter the list by typing Japanese, press Enter to commit the conversion, and the widget selects whatever option happened to be highlighted instead of accepting your text.
FixSame guard, applied to the select path: ignore Enter while the composition is active, so commit and select stop sharing a keypress.

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__)

← all writing greymoth — the record