Rapls AI Chatbot のチャット入力フォームを実装していて、また見覚えのあるバグに当たりました。
日本語で「ありがとうございます」と打って、変換を確定するために Enter を押した瞬間、まだ送るつもりのない文字列でチャットが送信されてしまったのです。
原因は、IME の変換確定に使う Enter と、フォーム送信用の Enter を、こちらの実装が区別できていなかったことでした。
「見覚えのある」と書いたのは、これが初めてではなかったからです。以前、Xserver 上で Long Polling を使った WordPress 向けのチャットシステムを試作していたときにも、同じ症状を踏んでいました。そのときは、その場しのぎで抑えて終わらせていたのですが、Rapls AI Chatbot を作っている途中で、また同じ問題が顔を出しました。
「また君か……」と思いました。
ただ、今回は自作プラグインの入力欄で起きた問題です。ユーザーが日本語でメッセージを書いている最中に勝手に送信されるのは、チャット UI としてかなり困ります。そこで、今回はちゃんと切り分けることにしました。
最初は KeyboardEvent.isComposing を見れば十分だと思っていました。MDN Web Docs でも、isComposing は compositionstart の後から compositionend の前までの合成セッション中かどうかを示すプロパティとして説明されています。MDN: KeyboardEvent.isComposing
ところが、実際に Safari でも確認してみると、isComposing だけでは止めきれないケースがありました。最終的に、私の環境では compositionend の時刻を記録して、その直後の Enter を捨てる実装に落ち着きました。
この記事では、IME 確定 Enter でフォームが誤送信される問題について、私が実際に試したこと、ブラウザごとの挙動差、最終的に採用したコードをまとめます。
先に結論だけ書くと
IME の変換確定 Enter とフォーム送信 Enter の区別は、isComposing だけでは取りこぼす場合があります。私の検証環境では、keydown 側で isComposing を見るだけでなく、compositionend の時刻も記録し、その直後(今回は 50ms 以内)の Enter を無視する二段ガードが一番安定しました。
検証時の環境:macOS 14 / Chrome 137 / Safari 18 / Firefox 138 / WordPress 6.9.4 / Cocoon 2.9.0 / 確認時期:2026年5月。この記事のブラウザ差分は、あくまで私の手元で確認した結果です。OS、IME、ブラウザバージョンによって挙動が変わる可能性があります。
何が起きていたか
入力フォームに、こんなシンプルな処理を書いていたとします。
|
1 2 3 4 5 |
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { sendMessage(); } }); |
英語入力だけなら、これでも大きな問題は起きにくいです。Enter を押したら送信される。それだけです。
しかし、日本語入力では話が変わります。
たとえば「ありがとう」と入力し、Space で変換候補を出し、Enter で確定する。この Enter は、本来「フォームを送信するための Enter」ではありません。IME に対して「この変換で確定します」と伝えるための Enter です。
ところが、フォーム側のコードはその区別をしていません。e.key === 'Enter' だけを見ているので、変換確定の Enter でも sendMessage() が走ってしまいます。
ユーザーから見ると、「まだ変換を確定しただけなのに、勝手に送信された」ように見えます。チャット欄、検索バー、コマンドパレット、問い合わせフォームなど、Enter で何かを実行する UI では同じ問題が起こり得ます。
私の環境で再現した手順
- 対象の入力欄を開く
- 日本語入力モードに切り替える
- 「ありがとう」と入力する
- Space を押して変換候補を出す
- Enter を押して変換を確定する
- この時点で送信処理が走れば、誤送信が起きている
ポイントは、英字入力だけでは再現しにくいことです。実際の IME、つまり macOS 標準 IME、Google 日本語入力、Windows IME、iOS/Android の日本語入力で確認しないと、この問題は見落としやすいです。
isComposing だけでは取りこぼしました
最初に試したのは、よく紹介されている isComposing チェックです。
|
1 2 3 4 5 |
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) { sendMessage(); } }); |
KeyboardEvent.isComposing は、キーボードイベントが文字の合成セッション中に発火したかどうかを示す読み取り専用プロパティです。MDN では、compositionstart の後から compositionend の前までを合成中として説明しています。MDN: KeyboardEvent.isComposing
そのため、最初は「Enter が押されたときに e.isComposing が true なら無視すればいい」と考えました。
Chrome では、この考え方でかなり素直に止まりました。ところが、Safari で試すと、まだ送信が走るケースがありました。
ここで分かったのは、変換確定 Enter のイベント順序が、ブラウザごとにきれいに揃っていないということです。
ブラウザ別にイベント順序を確認しました
原因を確認するために、keydown、compositionstart、compositionupdate、compositionend、input、keyup をすべて console.log に出しました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const events = [ 'keydown', 'compositionstart', 'compositionupdate', 'compositionend', 'input', 'keyup' ]; for (const name of events) { input.addEventListener(name, (e) => { console.log(name, { key: e.key, isComposing: e.isComposing, inputType: e.inputType, time: performance.now() }); }); } |
私の環境では、Chrome / Edge / Firefox では、おおむねこちらの順序になりました。
| 順序 | イベント | isComposing |
見え方 |
|---|---|---|---|
| 1 | keydown(Enter) |
true | まだ合成中として扱える |
| 2 | compositionend |
― | 変換確定 |
| 3 | input |
― | 確定文字列が反映される |
| 4 | keyup(Enter) |
false | キーを離す |
この順序なら、keydown 時点で e.isComposing が true なので、単純な isComposing チェックでも止められます。
一方、私の Safari 18 の環境では、こちらの順序になるケースを確認しました。
| 順序 | イベント | isComposing |
見え方 |
|---|---|---|---|
| 1 | compositionend |
― | 先に変換が終わる |
| 2 | input |
― | 確定文字列が反映される |
| 3 | keydown(Enter) |
false | 送信用Enterのように見えてしまう |
| 4 | keyup(Enter) |
false | キーを離す |
この場合、keydown が届いた時点では、すでに compositionend が終わっています。つまり e.isComposing は false です。
そのため、コードから見ると「普通の Enter が押された」と判断され、送信処理を通してしまいます。
ここで、isComposing 単独では足りないと判断しました。
最終的に採用したのは compositionend の時刻を見る方法です
私の手元で安定したのは、isComposing に加えて、compositionend の直後に来た Enter を無視する方法です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let lastCompositionEnd = 0; input.addEventListener('compositionend', () => { lastCompositionEnd = Date.now(); }); input.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; // 1. 合成中の Enter は無視 if (e.isComposing) return; // 2. compositionend 直後の Enter は、変換確定の Enter とみなして無視 if (Date.now() - lastCompositionEnd < 50) return; e.preventDefault(); sendMessage(); }); |
この実装では、Enter を受け取ったあとに、次の順番で判定しています。
- Enter 以外なら何もしない
e.isComposingが true なら、IME 合成中なので送信しないcompositionendから 50ms 未満なら、変換確定 Enter とみなして送信しない- どちらにも当てはまらなければ、通常の送信 Enter として扱う
Chrome 系では主に isComposing が効き、Safari では主に compositionend 直後の時間ガードが効く、という分担になりました。
50ms にした理由
50ms という数字は、仕様で決まっている値ではありません。私の環境で何度かログを取ったところ、Safari では compositionend から Enter の keydown までが、おおむね数ms〜20ms程度に収まっていました。そこに余裕を見て、今回は 50ms にしました。
| ブラウザ | 私の環境で確認した傾向 | 効いた対策 |
|---|---|---|
| Chrome 137 | keydown 時点で isComposing が true になりやすい |
isComposing でガード |
| Safari 18 | compositionend 後に Enter の keydown が届くケースを確認 |
直近 50ms の Enter を無視 |
| Firefox 138 | Chrome 系に近い挙動を確認 | isComposing でガード |
この値を大きくしすぎると、変換確定後にユーザーがすぐもう一度 Enter を押した場合、その送信操作まで止めてしまう可能性があります。逆に小さすぎると Safari 側の取りこぼしが出る可能性があります。
私のチャット UI では 50ms で違和感はありませんでした。ただし、これは私の環境での実装メモです。実運用では、対象ユーザーの環境で 30ms、50ms、80ms くらいを試して決めるのが安全だと思います。
input イベント側で送信しないようにしました
もうひとつ意識したのは、合成中の input イベントで送信ロジックを動かさないことです。
IME 入力中の input イベントでは、inputType が insertCompositionText などになることがあります。入力値の反映や候補表示なら input でよいのですが、送信のような確定操作まで混ぜると、イベントの見通しが悪くなります。
私の場合、送信トリガーは keydown に寄せ、input は入力値の追従だけにしました。その方が、あとからログを見たときにも「どこで送信されたか」を追いやすかったです。
React では nativeEvent を見るのが安全でした
React で同じ実装をするときは、少し注意が必要でした。
React のイベントハンドラに渡ってくるイベントは、ブラウザのイベントをそのまま渡しているのではなく、SyntheticEvent というラッパーです。React の旧公式ドキュメントでも、SyntheticEvent はブラウザのネイティブイベントを包むクロスブラウザ用のラッパーであり、必要に応じて nativeEvent から元のイベントにアクセスできると説明されています。React: SyntheticEvent
そのため、私は React では e.isComposing ではなく、e.nativeEvent.isComposing を見る形にしました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import { useRef } from 'react'; function ChatInput({ onSend }) { const lastCompositionEnd = useRef(0); const handleCompositionEnd = () => { lastCompositionEnd.current = Date.now(); }; 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 type="text" onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown} /> ); } |
タイムスタンプの保持には useState ではなく useRef を使いました。この値は画面表示に使うものではなく、イベントハンドラ内で最新値を参照したいだけです。useState にすると再レンダリングが増え、タイミング判定の用途としては扱いづらくなります。
Vue では @keydown.enter だけに頼らない
Vue 3 の Composition API で書くなら、こんな形にしました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<script setup> import { ref } from 'vue'; const emit = defineEmits(['send']); const text = ref(''); let lastCompositionEnd = 0; const onCompositionEnd = () => { lastCompositionEnd = Date.now(); }; const onKeyDown = (e) => { if (e.key !== 'Enter') return; if (e.isComposing) return; if (Date.now() - lastCompositionEnd < 50) return; e.preventDefault(); emit('send', text.value); }; </script> <template> <input type="text" v-model="text" @compositionend="onCompositionEnd" @keydown="onKeyDown" /> </template> |
Vue には @keyup.enter や @keydown.enter のようなキー修飾子があります。公式ドキュメントでも、@keyup.enter のように特定のキーのときだけハンドラを呼ぶ書き方が紹介されています。Vue: Key Modifiers
ただし、今回のように IME の合成状態を見たい場合は、単に @keydown.enter だけで済ませず、ハンドラ内で e.isComposing と compositionend 直後の判定を入れた方が安全でした。
チャットUIでは Shift + Enter も忘れない
Rapls AI Chatbot のようなチャット UI では、Enter で送信、Shift + Enter で改行、という操作にしています。
この場合も、IME 判定を先に行い、そのあとで Shift + Enter を改行として通すようにしました。
|
1 2 3 4 5 6 7 8 9 10 11 |
const onKeyDown = (e) => { if (e.key !== 'Enter') return; if (e.isComposing) return; if (Date.now() - lastCompositionEnd < 50) return; // Shift + Enter は改行として通す if (e.shiftKey) return; e.preventDefault(); sendMessage(); }; |
長文入力欄なら、Enter は改行、Cmd/Ctrl + Enter で送信の方が自然なこともあります。
|
1 2 3 4 5 6 7 8 9 10 11 |
const onKeyDown = (e) => { if (e.key !== 'Enter') return; if (e.isComposing) return; if (Date.now() - lastCompositionEnd < 50) return; // Cmd(macOS)または Ctrl(Windows/Linux)+ Enter のときだけ送信 if (!(e.metaKey || e.ctrlKey)) return; e.preventDefault(); sendMessage(); }; |
ここは UI の性格で決めます。短いチャットなら Enter 送信でもよいですが、問い合わせフォームや長文メモでは、Enter を送信に割り当てると入力ミスが増えやすいです。
モバイルでは keydown だけに寄せない
モバイル、特に iOS Safari では、ソフトウェアキーボードの「return」や「送信」キーが、デスクトップのような keydown として扱いやすいとは限りません。
そこで、モバイルではフォームの submit イベントでも拾えるようにしておく方が安定しました。
|
1 2 3 4 5 6 7 8 9 10 |
<form id="chat-form"> <input type="text" name="message" enterkeyhint="send" /> </form> <script> document.getElementById('chat-form').addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); }); </script> |
enterkeyhint は、仮想キーボードの Enter キーにどのようなラベルやアイコンを表示するかを伝える HTML のグローバル属性です。MDN では、send、search、done、enter などの値が説明されています。MDN: enterkeyhint グローバル属性
チャット欄なら enterkeyhint="send"、検索欄なら enterkeyhint="search" を指定しておくと、スマホのキーボード上でも意図が伝わりやすくなります。
自分のフォームで確認するときのチェック項目
実装したあと、私は次の項目を確認しました。
| 確認項目 | OKの状態 |
|---|---|
| 日本語入力中に Enter で変換確定 | 送信されない |
| 変換確定後にもう一度 Enter | 送信される |
| Shift + Enter | 改行される |
| Safari | 変換確定 Enter が送信扱いにならない |
| Chrome / Firefox | 通常送信とIME確定が分かれる |
| iOS / Android | フォーム submit でも送信できる |
特に Safari の確認は外さない方がいいです。Chrome だけで動いた状態を見て「直った」と思うと、私のようにあとで Safari に引っかかる可能性があります。
今回の実装で学んだこと
今回の問題は、表面的にはとても小さなバグです。Enter を押したら送信される。日本語入力中だけ、それが少し困る。最初はそのくらいに見えました。
でも実際に追ってみると、IME、ブラウザ、イベント順序、React の SyntheticEvent、モバイルの仮想キーボードまで関係していました。
最終的に、私の実装では次の形にしています。
keydownで Enter を検知するisComposingが true なら送信しないcompositionend直後の Enter も送信しない- React では
e.nativeEvent.isComposingを見る - モバイルでは
submitイベントも使う enterkeyhintでスマホのキー表示を整える
これで、私の検証環境では IME 確定 Enter による誤送信を防げました。
最後に
IME 確定 Enter の誤送信は、コードだけ見ると小さな問題に見えます。でも、日本語で入力するユーザーにとっては、かなりストレスの大きいバグです。
私も最初は isComposing だけで直ると思っていました。ところが、Safari で取りこぼしがあり、最終的には compositionend の時刻も見る形に落ち着きました。
同じ症状が出ている場合は、まず keydown、compositionstart、compositionupdate、compositionend、input、keyup を console.log に流してみるのがおすすめです。自分の環境でイベントがどの順番で届いているかを見るだけで、かなり原因を絞れます。
私自身、前に一度この問題を踏んだときは、ちゃんと整理しないまま終わらせてしまいました。今回あらためて向き合ったことで、次に同じ入力欄を作るときは、最初からこのガードを入れられます。同じところで半日止まっている方の助けになれば嬉しいです。
関連記事
- 姓名フォームのフリガナ自動入力をcompositionイベントで自前実装した話 ── 同じ IME composition イベントを使った姉妹実装。姓名フォームのフリガナ自動入力を自前で組んだ記録。
- 日本語フォームの半角カナ・全角英数を input と blur で使い分けて自動変換する話 ── 同じ日本語フォーム実装シリーズで、半角カナや全角英数の自動変換を input と blur で役割分担した話。
- Contact Form 7でzipaddr-jpが動かなかった話|郵便番号→住所自動入力で踏んだid命名規則の罠 ── 同じ WordPress フォーム実装で、郵便番号→住所自動入力プラグインの id 命名規則で踏み抜いた話。















コメント