Macでは問題なく動いていた入力欄が、WindowsでIMEの変換候補をマウス選択すると壊れました。
日本語対応のWebアプリを開発していると、次のようなQA報告を受けることがあります。
- Windowsだけ日本語入力がおかしい
- IME変換中のEnterで検索が実行される
- Macでは再現しない
- 特定の入力欄だけ、漢字へ変換してもひらがなのままになる
今回遭遇したのは、Windows環境でIMEの変換候補をマウスで選択すると、特定の入力欄だけひらがなのまま確定される不具合でした。
原因は、独自の@inputハンドラーがComposition中の未確定値をupdate:modelValueとしてemitし、Vueの再レンダリングによって入力欄へ書き戻していたことでした。
この記事では、次の内容を整理します。
- IME入力中に発生するイベント
- Vueのネイティブ
v-modelが持つComposition制御 - 独自
@inputやWrapper Componentで必要になる対策 - ブラウザによって異なるイベント順序
- IME確定Enterによる誤送信への対策
- 自分の環境でイベントを確認するロガー
今回遭遇した不具合
発生条件は次のとおりでした。
- Windowsでのみ再現
- IMEの変換候補をマウスで選択すると発生
- 漢字を選択しても、入力欄が変換前のひらがなへ戻る
- 他の入力欄では発生しない
- macOSでは再現しない
問題の入力欄だけが、ネイティブ要素のv-modelではなく、独自の@inputハンドラーからupdate:modelValueをemitしていました。
つまり、ネイティブ要素のv-modelが提供するComposition制御を、独自の値同期処理へ置き換えていたことが原因でした。
IME入力では何が起きているのか
英数字の直接入力と異なり、日本語入力には「未確定」の状態があります。
この間、ブラウザではComposition Eventsが発火します。
重要なのは、Composition中でもinputやkeydown、keyupなどのイベントが発火することです。
inputが発火したからといって、その値が確定済みとは限りません。
Composition中のinputを通常の値変更として扱うと、未確定のひらがなをアプリケーション状態へ取り込む可能性があります。
Composition中かどうかを確認する方法
| 方法 | 内容 |
|---|---|
KeyboardEvent.isComposing |
keydown・keyupがComposition中に発生したかを示す |
InputEvent.isComposing |
beforeinput・inputがComposition中に発生したかを示す |
InputEvent.inputType |
insertCompositionTextなど入力の種類を示す |
keyCode === 229 |
IMEが処理するキーボードイベントで現れることがある互換シグナル |
compositionstart / compositionend
|
Composition状態を自前で追跡する方法 |
検証環境
今回の調査期間(2026年1〜2月)に利用していた主要バージョンは次のとおりです。
| Browser | Version |
|---|---|
| Chrome | 144–145 |
| Safari | 26.2–26.3 |
Windows + ChromeおよびmacOS + Chromeのトレースは、実際の調査時に取得したものです。
イベントトレースと既知のブラウザ差異
Windows + Chrome(144–145) Enterで確定
keydown key="Process" keyCode=229 isComposing=false
compositionstart
compositionupdate
beforeinput
input isComposing=true
...
compositionupdate data="漢字"
input isComposing=true
keydown key="Process" keyCode=229 isComposing=true
input isComposing=true
compositionend
keyup key="Enter" isComposing=false
この環境では、確定値を含むinputもcompositionendより前に発火します。
つまり、
if (event.isComposing) return;
だけでは確定値まで無視してしまいます。
Composition中のinputを無視する実装では、compositionendでも同期処理が必要です。
Windows + Chrome(144–145) マウスで候補を確定
今回の不具合はこちらでした。
compositionstart
input
isComposing=true
[候補をマウスクリック]
input
isComposing=true
compositionend
マウスで候補を確定するため、確定操作に対応するキーボードイベントは発火しません。
Compositionの終了を明示的に通知するイベントはcompositionendです。
独自@inputがComposition状態を無視すると、
という状態になります。
macOS + Chrome
compositionupdate
input isComposing=true
compositionend
同じ実装でも、IME実装・イベント順序・DOM更新タイミングの組み合わせ次第では、独自@inputによる不具合は表面化しないこともあります。
「Macで動く」は、安全性を保証するものではありません。
Safari(26.2–26.3)
Safari 26.2〜26.3では、Composition EventsとKeyboardEventの順序に関する既知のWebKit Issueが存在していました。
compositionupdate
input isComposing=true
compositionend
keydown key="Enter" isComposing=false
keyup key="Enter" isComposing=false
compositionendの後にEnterのkeydownが届くため、
if (event.isComposing)
だけではIME確定Enterを除外できません。
この問題は WebKit Bug 311717 により2026年4月にWebKit本体で修正が確認されています。
ただし、WebKit本体への修正と安定版Safariへの反映時期は一致しません。
修正前のSafariをサポート対象に含める場合は、互換処理を残す必要があります。
Firefoxで報告されている順序
Firefoxでは、少なくとも従来の実装では、
compositionupdate
compositionend
input isComposing=false
という順序が報告されています。
代表的な違いは次のようになります。
| Browser family | Representative order |
|---|---|
| Chromium / WebKit(従来) | input → compositionend |
| Firefox(従来) | compositionend → input |
重要なのは、特定ブラウザの順序を前提に実装しないことです。
Vueのネイティブv-modelはIMEを考慮している
ネイティブ要素では、
<input v-model="value" />
だけでComposition制御が有効になります。
VueのvModelTextディレクティブは概ね次のような流れで動作します。
-
compositionstartで内部フラグを立てる - Composition中の
inputを無視する -
compositionendでフラグを解除する -
inputを再発火して確定値を同期する
そのため、通常のネイティブv-modelによる値同期では、Composition制御を自前で実装する必要はほとんどありません。
独自@inputではComposition制御が必要
Wrapper Componentでは次のような実装をよく見かけます。
<input
:value="modelValue"
@input="onInput"
/>
const onInput = (event: Event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
emit('update:modelValue', target.value);
};
ネイティブv-modelが持つComposition制御は、この実装には引き継がれません。
:valueと@inputで同期を実装する場合は、自分でComposition状態を扱う必要があります。
修正方法
以下は<script setup lang="ts">内の実装例です。
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const isComposing = ref(false);
const updateModelValue = (event: Event) => {
const target = event.target;
if (
!(target instanceof HTMLInputElement) &&
!(target instanceof HTMLTextAreaElement)
) {
return;
}
if (target.value === props.modelValue) return;
emit('update:modelValue', target.value);
};
const onInput = (event: Event) => {
if (isComposing.value) return;
updateModelValue(event);
};
const onCompositionStart = () => {
isComposing.value = true;
};
const onCompositionEnd = (event: CompositionEvent) => {
isComposing.value = false;
updateModelValue(event);
};
<input
:value="modelValue"
@input="onInput"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
/>
Composition中は同期を止め、compositionendで確定値だけを同期します。
<textarea>でも考え方は同じです。
上記の実装はHTMLInputElementとHTMLTextAreaElementの両方を対象にしています。
Enter送信にも別の罠がある
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Enter') return;
search();
};
IME入力中のEnterは「送信」ではなく「変換候補の確定」です。
基本的には
if (event.isComposing)
で除外できます。
修正前のSafariをサポートする場合は、
const onKeydown = (event: KeyboardEvent) => {
const isImeEvent = event.isComposing || event.keyCode === 229;
if (isImeEvent || event.key !== 'Enter') return;
search();
};
のように229をフォールバックとして利用できます。
keyCodeはDeprecatedですが、互換目的では現在も利用されています。
テストするときに確認したい組み合わせ
最低限確認したい組み合わせは次のとおりです。
- Chrome + Windows
- Enter確定
- マウスで候補確定
- Edge + Windows
- Chrome + macOS
- Safari + macOS
- Firefox(可能であれば)
PlaywrightなどではネイティブIMEを完全には再現できません。
最終確認は実際のOS・IMEで行うことをおすすめします。
どの実装でComposition対応が必要か
| 実装 | 対応 |
|---|---|
<input v-model> |
基本不要 |
:value + 独自@input
|
必要 |
| Wrapper Component | 必要 |
| Enter送信 |
isComposingでガード |
| 修正前Safari対応 |
keyCode === 229を検討 |
watchでDOMへ書き戻す |
要注意 |
| DirectiveでDOMを書き換える | 要注意 |
| UIライブラリ | ライブラリの実装を確認し、独自ハンドラは別途監査 |
DevToolsでイベントを確認する
イベントロガー
次のJavaScriptをDevToolsのコンソールに貼り付けると、対象のinput要素で発火したKeyboard Events、Input Events、Composition Eventsを確認できます。
const inputElement = document.querySelector('input');
const eventTypes = [
'keydown',
'keyup',
'beforeinput',
'input',
'compositionstart',
'compositionupdate',
'compositionend',
];
eventTypes.forEach((eventType) => {
inputElement?.addEventListener(eventType, (event) => {
console.log(
event.type.padEnd(18),
`key=${event.key ?? '-'}`,
`keyCode=${event.keyCode ?? '-'}`,
`inputType=${event.inputType ?? '-'}`,
`data=${event.data ?? '-'}`,
`isComposing=${event.isComposing ?? '-'}`,
`value=${inputElement.value}`,
);
});
});
複数の入力欄がある場合は、対象を特定できるセレクターへ変更してください。
const inputElement = document.querySelector('[data-ime-debug]');
検証条件も別途記録する
上記のロガーが出力するのは、ブラウザイベントの内容だけです。
イベント順序を比較する際は、OSやブラウザ、IME、候補の確定方法によって結果が変わるため、次の検証条件も別途記録しておくと比較しやすくなります。
| 項目 | 記録例 |
|---|---|
| OS | Windows 11 24H2 |
| Browser | Chrome |
| Browser version | 145.0.x |
| IME | Microsoft IME |
| IME version | 確認できる場合のみ |
| 入力文字列 | かんじ |
| 変換方法 | Spaceで変換 |
| 候補の確定方法 | Enter / マウスクリック |
ブラウザ名だけでなく、バージョンとIME、候補の確定方法まで残しておくことが重要です。
まとめ
ネイティブ要素のv-modelはComposition中の入力を考慮しています。
しかし、その保護は独自の:value + @inputやWrapper Componentへは引き継がれません。
値同期を自前で実装する場合は、
- Composition中は同期しない
-
compositionendで確定値を同期する - Enter送信は
isComposingで除外する - 修正前Safariを考慮する場合は
keyCode === 229をフォールバックとして検討する
という方針にすると、主要ブラウザの違いを吸収しやすくなります。
参考資料
- Vue Guide: Form Input Bindings
- Vue Guide: Component v-model
- Vue core:
vModelTextimplementation - MDN: CompositionEvent
- MDN: compositionstart
- MDN: compositionupdate
- MDN: compositionend
- MDN: KeyboardEvent.isComposing
- MDN: InputEvent.isComposing
- MDN: InputEvent.inputType
- Can I Use: Composition Events
- W3C UI Events Issue #202: Event order between
compositionendandinput - WebKit Bug 165004: CompositionイベントとKeyboardEventの順序
- WebKit Bug 311717: Event order fix
- Chrome Releases: Chrome 144
- Chrome Releases: Chrome 145
- Safari 26.3 Release Notes