数字の「1」を一度押しただけなのに
1. 謎のバグ報告:「数字が二重に入力されます」
ある日、クライアントからシンプルながらも奇妙な問い合わせが寄せられました。
「Windows 11環境で認証コードを入力する際、数字を一つ押すと二つの欄に同じ数字が入力されてしまいます。」
6桁の認証コードを入力する、ごく普通のOTP認証コンポーネントでした。私のMacBookでは、どのような方法でテストしても問題は発生しませんでした。さらには、テスト用のWindows PCでもバグは再現されませんでした。
追加の確認事項をクライアントに確認してみました。
- 事象が確認されたOS(windows/mac OSなど)
→Windows11PRO
- 事象が確認された利用ブラウザ(microsoft edge/chromeなど)
→microsoft edge
- 別のブラウザを使用したときに、同じ事象が発生するか。
→発生する
- お客様の別のPCで、同じ事象が発生するか。 他のPCで同じ事象が発生するか
→発生する
「クライアントのPC環境の問題だろうか?」と思いましたが、クライアントは他のPCでも同様の問題が発生すると知らせてくれました。
他の場所への入力は問題なく、このコンポーネントでのみ発生していたため、単純な問題ではないと直感しました。
2. 犯人は「最新のWindows IME」
MacBookとWindows PCの違い、そしてインターネット検索で突き止めたのは、やはり 「IME(入力メソッドエディタ)」 に何か問題がある!ということでした。
「もしかしてIMEのバージョンの問題?」
この仮説に疑問を抱いた私は、自らWindows PCの設定画面に入り、関連オプションを探し始めました。そしてついに、決定的な手がかりを見つけ出したのです。
設定 > 時刻と言語 > 言語と地域 > 日本語 > Microsoft IME > 全般
のパスにある 「以前のバージョンのMicrosoft IMEを使う」 というオプションでした。
windowsIME option off
このオプションをオフにしたとき(つまり、最新のIMEを使用したとき)、嘘のようにバグが100%再現されました。犯人は 「新しいWindows IME」 と認証コンポーネントの相互作用が問題だったことが明らかになりました。
新しいIMEはイベントの発生タイミングが従来と異なり、認証コンポーネントの onInput
イベントとフォーカス移動ロジックの間に後続のイベントを発生させ、二重入力問題を引き起こしていたのです。
通常の場合、このような入力問題は発生しませんでしたが、認証コンポーネントの場合、数字が入力されると自動的に次の入力欄にフォーカスを移動するように作られていました。
このフォーカス移動というイベントとIMEの入力タイミングが互いに衝突し、二重に入力されるバグが発生したのです。
最初の解決策:onCompositionEvent
IME問題に対する標準的な解決策は、onCompositionStart/End
イベントを使用してIMEの「編集中」状態を追跡することでした。isComposing
という状態フラグを設け、IMEが文字を変換中(編集中)はロジックの実行を止め、変換が終了したときにのみ実行するようにコードを修正しました。
// 最初の試み
const [isComposing, setIsComposing] = useState(false);
//...
<Input
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onInput={(e) => {
if (isComposing) return;
// ... ロジック実行 ...
}}
/>
ついに二重入力バグが解消されました!しかし、勝利の喜びも束の間、より巧妙な問題が現れました。それはユーザーエクスペリエンス(UX)の低下でした。
- 全角数字の問題: 日本語IMEの全角モードで数字を入力すると、以前は即座に半角に変換されましたが、今では
Enter
キーを押さないと入力が確定されなくなりました。 - 文字入力の問題: 本来は数字以外の文字は入力すらできませんでしたが、今では
か
のような文字が入力欄に一時的に表示され、Enter
を押すと消えるという現象が発生しました。
バグは修正したものの、製品の完成度はむしろ下がってしまいました。再び別の方法を探さなければなりませんでした。
3. 続く問題:非同期、レースコンディション、そしてUX
ここからの目標は明確になりました。バグを修正しつつ、既存のUXを一つも損なわないこと。
この時から、再びIMEとの戦いが始まりました。
-
2次試行(
useRef
によるロック):useState
の非同期的な特性が問題かもしれないという仮説のもと、即座に値が反映されるuseRef
を利用した「ロック」機構を導入しました。一つのイベントが処理中の間は、他のイベントが割り込めないようにする方式です。 -
3次試行(
setTimeout
トリック): しかし、ユーザーの入力速度が非常に速い場合、ロックが解除された直後に後続のイベントが到達するレースコンディションが依然として存在しました。setTimeout(..., 10)
を利用して、ロック解除のタイミングを微調整するトリックを使用しました。 -
4次試行(UX改善):
blur()
の代わりにselect()
、そして再びsetSelectionRange()
に変更しながら、最後の入力欄のUXを調整しました。
これでほぼ全てが完璧に見えました。しかし、最後の問題が残っていました。onKeyDown
で preventDefault()
を使って意図しない入力を防いだにもかかわらず、数字以外の文字を入力する際に、ごくわずかな時間だけIMEの変換候補ポップアップが表示される問題が解決されませんでした。
こんな変換候補ポップアップ…スクリーンショットはmacOSのものです
そこで、こう考えました。
最新のWindows IMEは、ウェブ標準のイベント制御を一部無視し、ブラウザのレンダリングに直接介入している。したがって、通常の方法では勝てない。
4. 最後の手段:属性を切り替える
これまでの問題を一段落で要約すると、次のようになります。
最新のWindows IMEは、onInput
イベントの直後に、ほぼ同期的に追加の「完了」イベントを発生させているように見えます。認証コードコンポーネントは、最初の onInput
で値を処理し、即座に次の入力欄へフォーカスを移動させますが、このとき、追加のイベントが新たにフォーカスされた入力欄を対象として発生し、onInput
ハンドラが再呼び出しされるというレースコンディションが発生します。
この問題を解決するために最後に選んだ方法は、「最後の手段」でした。IMEを制御できないのであれば、IMEが動作する環境そのものを瞬間的に無くしてしまえばいいのです。
それが、readOnly
属性を利用した強制初期化テクニックです。
// IMEの予期せぬ動作を強制的にリセットするヘルパー関数
const resetImeState = (target: HTMLInputElement) => {
target.readOnly = true; // 瞬間的に読み取り専用にしてIMEを無効化
setTimeout(() => {
target.readOnly = false; // すぐに再度有効化
target.focus();
// 最後の入力欄ではカーソルを数字の後ろに移動させてUXを維持
if (!inputs[codeIndex + 1] || codeIndex === 5) {
target.setSelectionRange(1, 1);
}
}, 10);
};
数字ではない入力や、6桁目の入力が終わるなど、IMEポップアップが表示されうるすべての問題状況で、この resetImeState
関数を呼び出しました。入力欄を瞬間的に「読み取り専用」にしてから元に戻す方法です。これにより、ブラウザに入力欄の状態を完全に再評価させ、表示されていたIMEポップアップを強制的に閉じさせました。
この readOnly
トリックと useRef
によるロック、そして onKeyDown
での先制防御という3つの強力なテクニックを組み合わせることで、ようやく問題を解決し、既存のUXをすべて取り戻すことができました。
属性を変更するという点と、非常に速く入力すると一瞬だけ変換候補ポップアップが表示される問題は気になりましたが、現状ではこれが最善策ではないかと考えています。
おわりに
今回のバグ解決の過程は、単なるミス修正ではありませんでした。フロントエンド開発の最も深い部分、ブラウザとオペレーティングシステムが相互作用する領域を探検するようなものでした。この経験を通じて学んだことは、以下の通りです。
- 再現しないバグは、「環境」の違いに答えがある。
- 標準的な解決策が通用しないときは、問題の根本原理を疑うべきだ。
- ユーザーからの鋭いフィードバックは、どんな技術文書よりも優れた道しるべである。
何よりも、他のチームメンバーも把握できていなかった原因を発見し、解決まで至ったという点で自信を得ることができました。ブラウザによる対応はしたことがありますが、このようにOSの違いやIMEのバージョンによる動作にまで対応するのは初めてで、本当に良い経験になったと思います。