日本語で「ありがとうございます」と打って、変換を確定するために Enter を押した瞬間、まだ送るつもりのない文字列でチャットが送信されてしまう。こんな経験はありませんか。Rapls AI Chatbot の入力フォームを実装していて、私はまたこのバグに当たりました。原因は、IME の変換確定に使う Enter と、フォーム送信用の Enter を、実装が区別できていなかったことです。「また」と書いたのは、以前 Xserver で Long Polling のチャットを試作したときにも同じ症状を踏んで、その場しのぎで終わらせていたからです。今回はちゃんと向き合いました。
先に答えを書きます。IME の変換確定 Enter とフォーム送信 Enter の区別は、isComposing だけでは取りこぼします。私の環境で安定したのは、keydown で isComposing を見るだけでなく、compositionend の時刻も記録して、その直後(今回は 50ms 以内)の Enter を無視する二段ガードでした。Chrome 系では主に isComposing が効き、Safari では主にこの時間ガードが効く、という分担になります。
検証環境:macOS 14 / Chrome 137 / Safari 18 / Firefox 138 / WordPress 6.9.4 / Cocoon 2.9.0(2026年5月)。ブラウザ差分は私の手元で確認した結果で、OS・IME・バージョンで変わる可能性があります。

なぜ、確定 Enter で送信されるのか
素朴に書くと、入力欄の処理はこうなりがちです。
| 1 2 3 | input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMessage(); }); |
英語入力だけなら、これでも大きくは困りません。問題は日本語入力です。「ありがとう」と入力し、Space で変換し、Enter で確定する。この Enter は、フォーム送信用ではなく、IME に「この変換で確定します」と伝えるための Enterです。でもコードは e.key === ‘Enter’ しか見ていないので、確定 Enter でも sendMessage() が走る。ユーザーから見れば、変換を確定しただけなのに勝手に送信された、ように見えます。チャット欄、検索バー、問い合わせフォームなど、Enter で何かを実行する UI で共通して起きます。余談ですが、テストを英語キーボードだけで済ませる癖があると、この手のバグは本当に見つかりません。私は一度痛い目を見たので、日本語入力での確認だけは省かないようにしています。
isComposing だけでは、なぜ足りないのか
最初に試したのは、よく紹介される isComposing チェックです。Enter のとき e.isComposing が true なら無視する。Chrome ではこれで素直に止まります。ところが Safari では、まだ送信が走るケースがありました。原因は、変換確定 Enter のイベント順序が、ブラウザごとに揃っていないことでした。6つのイベント(keydown / compositionstart / compositionupdate / compositionend / input / keyup)をログに流して、順序を確かめました。
Chrome / Edge / Firefox では、Enter の keydown が先に来て、その時点で isComposing が true です。だから isComposing チェックで止まります。Safari では、先に compositionend が終わってから Enter の keydown が届くケースがあり、そのとき isComposing は false。コードからは「普通の Enter が押された」と見えて、送信を通してしまう。これが Safari の取りこぼしの正体でした。
| 順序 | Chrome / Firefox | Safari 18 |
|---|---|---|
| 1 | keydown(Enter、isComposing=true) | compositionend |
| 2 | compositionend | input |
| 3 | input | keydown(Enter、isComposing=false) |
| 4 | keyup(Enter、false) | keyup(Enter、false) |
二段ガードで、両方を拾う
手元で安定したのは、isComposing に加えて、compositionend の直後に来た Enter を無視する方法です。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | let lastCompositionEnd = 0; input.addEventListener('compositionend', () => { lastCompositionEnd = Date.now(); }); input.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; if (e.isComposing) return; // 合成中の Enter は無視 if (Date.now() - lastCompositionEnd < 50) return; // 確定直後の Enter も無視 e.preventDefault(); sendMessage(); }); |
50ms は仕様値ではありません。私の環境では、Safari の compositionend から Enter の keydown までが、おおむね数 ms 〜 20ms に収まっていたので、余裕を見て 50ms にしました。大きすぎると、確定後にすぐもう一度 Enter を押した送信まで止めてしまう。小さすぎると Safari の取りこぼしが出る。私のチャット UI では 50ms で違和感はありませんでしたが、実運用では対象環境で 30 / 50 / 80ms あたりを試すのが安全です。あわせて、送信トリガーは keydown に寄せ、input は入力値の追従だけにしておくと、あとからログを見たときに「どこで送信されたか」を追いやすくなります。
React と Vue では、どこを見るか
React のイベントは SyntheticEvent というラッパーなので、e.isComposing ではなく e.nativeEvent.isComposing を見ます。時刻の保持は useState ではなく useRef を使います(画面表示に使う値ではなく、ハンドラ内で最新値を参照したいだけなので、再レンダリングを避けられます)。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | function ChatInput({ onSend }) { const lastCompositionEnd = useRef(0); const handleKeyDown = (e) => { if (e.key !== 'Enter') return; if (e.nativeEvent.isComposing) return; if (Date.now() - lastCompositionEnd.current < 50) return; e.preventDefault(); onSend(); }; return ( <input onCompositionEnd={() => (lastCompositionEnd.current = Date.now())} onKeyDown={handleKeyDown} /> ); } |
Vue 3 なら @compositionend と @keydown を併用し、@keydown.enter だけに頼らず、ハンドラ内で同じ二段判定(isComposing と compositionend 直後)を入れます。キー修飾子は便利ですが、IME の合成状態までは見てくれないからです。
Shift+Enter とモバイルも決めておく
チャット UI では、Enter で送信、Shift+Enter で改行にしました。IME 判定を先に通したあとで、e.shiftKey なら return して改行を通します。長文入力欄なら、Enter は改行、Cmd / Ctrl+Enter で送信のほうが自然なこともあります(!(e.metaKey || e.ctrlKey) なら return)。ここは UI の性格で決めます。
モバイル、特に iOS Safari では、ソフトウェアキーボードの送信キーが、デスクトップのような keydown として扱えるとは限りません。そこで、フォームの submit イベントでも拾えるようにして、デスクトップは keydown、モバイルは submit、と二系統にしておくと安定しました。あわせて入力欄に enterkeyhint=”send” を付けると、スマホのキーボード右下キーが「送信」になり、意図が伝わりやすくなります(検索欄なら search)。
最後に確認した項目
実装後、この項目を確認しました。特に Safari は外さないほうがいいです。Chrome だけで動いた状態を見て直ったと思うと、あとで Safari に引っかかります。
| 確認項目 | OK の状態 |
|---|---|
| 日本語入力中に Enter で変換確定 | 送信されない |
| 変換確定後にもう一度 Enter | 送信される |
| Shift + Enter | 改行される |
| Safari の変換確定 Enter | 送信扱いにならない |
| iOS / Android | フォーム submit でも送信できる |
冒頭の「また君か……」に戻ります。前に一度踏んだのに整理しないまま終わらせたから、今回また顔を出しました。今回ちゃんと向き合ったので、次に同じ入力欄を作るときは、最初からこのガードを入れられます。同じ症状で止まっているなら、まず keydown / compositionstart / compositionupdate / compositionend / input / keyup を console.log に流して、自分の環境でイベントがどの順で届くかを見てください。順序が分かれば、原因はかなり絞れます。
関連記事
- 姓名フォームのフリガナ自動入力を composition イベントで自前実装した話。同じ IME composition イベントを使った姉妹実装です。
- 日本語フォームの半角カナ・全角英数を input と blur で使い分けて自動変換する話。同じ日本語フォーム実装シリーズです。
- Contact Form 7 で zipaddr-jp が動かなかった話|郵便番号から住所自動入力で踏んだ id 命名規則の罠。同じ WordPress フォーム実装での罠です。


コメント