DEV Community

greymoth
greymoth

Posted on

The Enter key that submits your form while a Japanese user is still typing

Here's the whole lesson up front, so you can leave after one paragraph if you want:

If your text field submits on Enter, it almost certainly submits on the Enter a Japanese, Chinese, or Korean user presses to confirm a word. That Enter isn't "send." It's "yes, that kanji." Your handler can't tell the difference unless you check one flag, and your English test suite will pass green forever while this ships. The flag is event.isComposing.

That's it. The rest is why it happens, why CI is blind to it, and a free way to pin it so it doesn't crawl back.

What actually happens

Japanese, Chinese, and Korean don't map one key to one character. You type a phonetic guess, the IME shows candidates, and you press Enter (or Space, then Enter) to pick one. That confirming Enter fires a keydown with key: "Enter", same as any other. If your submit handler only looks at key, it fires. The user was mid-word. Their first attempt is gone.

The tell is that it eats the first one. A Japanese user types a message, hits Enter to confirm the conversion, and the form submits with half a sentence, or the tag commits early, or the command palette runs the highlighted command. They learn to type, confirm somewhere else, then paste. That's the workaround real users invent for your bug.

I hit this in a Vue library, naive-ui. Its n-dynamic-tags committed a tag on the Enter that confirmed an IME conversion, so you couldn't type a multi-character CJK tag without it splitting early. The fix that got merged is small on purpose:

// inside the Enter handler
if (inputInstRef.value?.isCompositing) return
Enter fullscreen mode Exit fullscreen mode

Guard the handler while composition is active, and the confirming Enter does nothing. The real Enter, the one after compositionend, still commits. Twenty-nine lines including the changelog and the test. The bug had been there a while; nobody typing in English would ever meet it.

Why your CI never sees it

This is the part that matters for anyone shipping to a global audience. You don't reproduce this by reading the code. You reproduce it by having an IME on and composing a word. Nobody on the review is typing 日本語 into the field. So the diff looks fine, the tests are green, and the regression ships.

The portable guard, if you're not in a framework that wraps it:

input.addEventListener('keydown', (e) => {
  if (e.isComposing || e.keyCode === 229) return // IME is mid-composition
  if (e.key === 'Enter') submit()
})
Enter fullscreen mode Exit fullscreen mode

e.isComposing is true between compositionstart and compositionend. keyCode === 229 is the legacy signal for the same state and still shows up on older Safari and some Android keyboards. In React you read it off e.nativeEvent.isComposing, because the synthetic event doesn't always carry it. Frameworks differ in the spelling; the idea is identical.

So the fix is trivial. The problem is that "fix it once" and "keep it fixed" are different jobs. There's no lint rule that reliably flags "this Enter handler forgot about composition," and the next refactor that touches the handler can drop the guard, and again, no English-only test goes red. It comes back within a release or two. I've watched it come back.

Pinning it so it can't come back

The only thing that keeps this dead is a test that composes a word and asserts the submit didn't fire. That's a specific, slightly annoying test to write, and it's the same test every project needs, which is exactly the kind of thing worth sharing instead of everyone re-deriving it.

So I put the cases in a small MIT package: @greymoth/cjk-agent-fixtures. It's a runnable regression fixture pack for eleven of these input bugs, in JavaScript (Vitest/Jest) and Go, standard library only. For the IME case it hands you the keyboard/composition event sequence and the correct result, and you replay it against your own handler:

import { editorCases, applyEvents } from '@greymoth/cjk-agent-fixtures'
import { createInput } from '../src/text.js' // your code

it.each(editorCases)('$slug', ({ events, correct }) => {
  const input = applyEvents(createInput(), events)
  expect(input.submitted).toBe(correct.submitted) // false during composition
})
Enter fullscreen mode Exit fullscreen mode

Be clear about what that is. It's not a scanner. It doesn't read your bundle and guess whether you're vulnerable. You point it at your functions, it holds the inputs and the expected answers, and your CI goes red when your handler gets it wrong. Every case also carries the wrong value a common broken handler returns, so you can confirm the test actually bites before you trust the green.

The other ten, briefly

The IME Enter is one of eleven, and they cluster into a few wrong assumptions about text. A quick sense of the neighbours, because if you have one you probably have three:

  • A byte slice through 日本語 (3 bytes per char) lands mid-character and prints U+FFFD.
  • str.length over-counts a rare kanji like 𠮷 or any emoji, and a slice at an odd UTF-16 boundary leaves a lone surrogate.
  • A field of only full-width spaces (  , U+3000, what the IME types on the space bar) passes your ASCII .trim() "not empty" check.
  • Half-width katakana ハンカク and ハンカク compare unequal, so your "username already taken" check misses the collision.

Same shape every time: code that was written assuming one character is one byte is one column in one encoding, meeting text where none of that holds. The full taxonomy and a receipt (a real PR) for each is in the corpus.

Honest limits

  • The IME guard has genuine edge cases. Some browsers keep isComposing true after focus leaves mid-composition, so a naive guard can freeze the field until refocus. The fixtures cover that as a separate case (#5), but if you only copy the one-liner above you can trade one bug for another.
  • Fixtures don't find your bug for you. If your Enter handler lives somewhere the cases can't reach without a five-line adapter, that's real work, not a drop-in.
  • If your product genuinely has zero CJK/RTL/emoji users and never will, this is ceremony. I don't think that's most products shipping in 2026, but it's a real out.

If one confirming-Enter test saves one Japanese user from losing their first message, it paid for itself. That's the entire pitch. No account, no signup, MIT, works offline.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.