タグ
ほぼ黒のカードに白のpagefindのマークとロゴタイプ。マークは針を囲む楕円で、その横に小文字のロゴタイプが並ぶ

Pagefind の Enter をキャプチャフェーズで止めて日本語検索を直す — isComposing だけでは足りない理由

IME の変換中に Pagefind がページ遷移していました。isComposing は必要ですが十分ではありません。WebKit は keydown より先に compositionend を発火します。

目次

はじめに

自分のブログで「東京」を検索しようとしたら、言語モデルの歴史の記事に飛ばされました。

日本語を打つということは IME を通して打つということです。この単語にたどり着くには toukyou と入力し、IME が「とうきょう」という読みを下線付きで表示し、Space で変換候補を出し、 Enter で目的の候補を確定します。この打鍵はすべてページにも届くので、IME にとって 「その漢字でよい」を意味する Enter が、検索ボックスにとっては「実行」になります。 React で同じ問題に当たったときは条件に e.nativeEvent.isComposing を足して直したので、 今回も同じ 1 行で済むと考えていました。

そうではありませんでした。理由は 2 つあります。検索ボックスは Pagefind のものであって 自分のものではないため、条件を足すべき自分のコードがそもそも存在しません。そして isComposing だけでは穴は塞がりません。WebKit は変換を終わらせた keydown より先に compositionend を発火するからです。本記事では、このバグが実際には何だったのか、 なぜ自明に見えるプロパティ判定が必要ではあっても十分ではないのか、そして修正が最終的に ベンダーのバンドルより上のキャプチャフェーズのリスナーになった経緯を整理します。

検索ボックスは自分のものではない

このサイトの検索は Pagefind 自身の <pagefind-searchbox> という Web コンポーネントです。 属性を渡してカスタムプロパティでテーマを合わせる、こちらの関与はそれだけです。 キーボード処理は pagefind --site distdist/pagefind/ に出力する、圧縮済みの バンドルの中にあります。

この一点だけで、React 式の修正は成り立ちません。isComposing はネイティブの DOM プロパティであって React の発明ではないので、React の e.nativeEvent.isComposing は 文字どおり同じプロパティであり、素の Astro サイトでもそのまま通用します。しかし プロパティ判定はハンドラの中に書くしかなく、そのハンドラは自分が編集できるものでは ありませんでした。

問題の分岐は、ベンダーのソースでは次のようになっています。

pagefind_ui/component/components/pagefind-searchbox.ts
this.inputEl.addEventListener("keydown", (e) => {
switch (e.key) {
case "Enter":
if (this.isOpen && this.activeIndex >= 0) {
e.preventDefault();
this.activateCurrentSelection(e);
}
// …
}
});

変換中かどうかの判定は、どこにもありません。

activeIndex がすでに 0 だったので Enter で遷移した

当初の見立ては「余計な送信が起きるだけ」でした。煩わしいが復帰はできる、Escape を押して やり直せばよい類のものだと考えていました。activateCurrentSelection() を読んで、それが 変わりました。 末尾が window.location.href = … だったからです。そこで、変換中に activeIndex が現実に 0 以上になりうるのかが問題になり、答えは推論ではなく実際のページに尋ねて得られました。

{ value: 'とうきょう', isOpen: true, activeIndex: 0, results: 16 }

結果が描画された時点で activeIndex はすでに 0 です。 Pagefind の input イベントは 変換中にも発火するため、書きかけの読みでドロップダウンが開き、利用者が漢字を選ぶ前に 最初のヒットがハイライトされます。危険な状態に至るのに矢印キーは要りません。IME に向けた Enter が最初の分岐に入り、そのまま遷移します。

これを公開中のサイトに対して実行したところ、机上の話ではなく実際に起きることが確認できました。 とうきょう を変換中に確定の Enter を送ると、ブラウザは /ja/search から言語モデルの誕生に ついての記事へ移動します。冒頭でそこに飛ばされたのと同じ経路です。矢印キーはこれをさらに 悪化させます。IME の変換候補を送るのは矢印キーであり、Pagefind はそれを自分の選択を動かすために 消費するので、文字を選んでいる最中にハイライトされた検索結果がずれていきます。

キャプチャフェーズのリスナーがベンダーのハンドラより先に keydown を止めるイベントは document から pagefind-searchbox を経て input へと下向きに伝わる。IME ガードは document のキャプチャフェーズで待ち受けるため、input より先にイベントを受け取る。Pagefind は input 自身のターゲットフェーズで待ち受けており、そのコードは編集できない圧縮済みバンドルの中にある。keydown が変換中のものであれば、ガードが stopPropagation を呼んで経路を断ち、イベントはベンダーのリスナーに届かない。キャプチャフェーズ、上から下へdocumentIME ガードはここで待ち受ける自分のコード。ターゲットより先に動く。stopPropagation()<pagefind-searchbox><input>keydown, ターゲットフェーズPagefind はここで待ち受ける圧縮済みバンドル。編集できない。ここには届かない

ベンダーのリスナーは、編集できないバンドルの中から input のターゲットフェーズに載っています。 キャプチャは上から下へ進むので、document のリスナーが同じ keydown を先に見ます。

最初の試み: プロパティ判定、そして非推奨のヒント

自分が所有していないリスナーに届く前にイベントを止める仕組みが、キャプチャフェーズです。 キャプチャは document からターゲットの親まで、ターゲットフェーズのリスナーより先に 下りてくるので、input より上のキャプチャリスナーが keydown を先に見ることになり、そこで stopPropagation() を呼べばベンダーのリスナーには届きません。エージェントがこの形を提案し、 isComposing を軸にした最初の版を書きました。あわせて、多くの IME 向け解説が推奨している keyCode === 229 の判定も入れています。

pnpm check は 1 行を除いてクリーンでした。

src/scripts/pagefind-ime-guard.ts:50:33 - warning ts(6385): 'keyCode' is deprecated.
if (event.isComposing || event.keyCode === 229) return true;

集計は 0 errors, 0 warnings, 1 hint です。このリポジトリではヒントを「通ればよいもの」では なく「読むもの」として扱っているので、この用途に非推奨でない代替があるかを尋ねました。 答えは「ない」で、ヒントはそのまま受け入れることになるだろうと予想していました。

代わりのプロパティはありませんでしたが、より良い仕組みはありました。 keyCode === 229 は「エンジンは変換中なのに isComposing を立てていない」状態の代用であり、 その状態は compositionstartcompositionend で直接観測できます。この 2 つは isComposing より古く、変換を行うあらゆるエンジンで発火します。エージェントはガードを この 2 つのイベントを軸に書き直しました。ヒントは消え、カバー範囲はむしろ広がりましたkeyCode の判定では決して捕まえられないエンジンも、コンポジションイベントなら捕まえられる からです。

実際に効いた方法: 変換状態を自分で追う

最終的なガードは、次の 3 つのいずれかが成り立つとき、その keydown を変換の一部として扱います。 どれか 1 つだけでは完全ではないからです。

シグナル 捕まえる対象
event.isComposing 通常のケース。Chromium と Gecko の変換中
compositionstart は来たが compositionend がまだ来ていない 変換中のキーに isComposing を立てないエンジン
compositionend から 50 ミリ秒以内の keydown WebKit。確定の keydown より先に compositionend を発火する

驚いたのは 3 行目です。Chromium と Gecko では、確定の Enter は keydown、compositionend、 keyup の順に届くので、keydown の時点で isComposingtrue であり、何も問題は起きません。 WebKit はこの最初の 2 つを逆にします。同じ Enter の keydown が届く時点で変換は形式的に 終わっており、isComposing はすでに false です。このプロパティだけで組んだガードは、 Chrome のテストをすべて通り、Safari で落ちます。

IME 変換を確定する Enter のイベント順序2 つのタイムラインの比較。Chromium と Gecko では確定用の Enter がまず keydown を発火し、このとき isComposing は true で、続いて compositionend、keyup と続く。WebKit では compositionend が先に発火するため、同じ打鍵の keydown は isComposing が false となり、このプロパティだけに頼るガードは取りこぼす。compositionend から測る猶予時間が WebKit の場合を補う。Chromium / GeckokeydownisComposing: truecompositionendkeyup変換中に keydown が届くため、isComposing は true になります。WebKitcompositionendkeydownisComposing: falsekeyup50 ミリ秒の猶予でこの隙間を埋めるcompositionend が先に発火するため、同じ keydown は false を返します。

1 回の打鍵、2 通りの順序。WebKit は同じ Enter の keydown より先に変換を終えるので、 ガードが見ようとしたプロパティはすでに false になっています。

この 50 ミリ秒の枠は次の keyup で閉じられるため、意図して 2 回目に押した Enter が 飲み込まれることはありません。フラグが立ちっぱなしにならないよう、細かい規則が 2 つあります。 1 つは focusout でフラグを落とすこと。クリックで離れて放棄された変換には compositionend が 来ないからです。もう 1 つは、そのクリアを「検索ボックスの中かどうか」の判定よりに 走らせることです。この順序が効くのは、ドキュメントから外れた要素は closest() も一緒に 連れて行ってしまうためで、true のまま固まったフラグはそのページが生きている限り、 対象のキーをすべて黙って飲み込みます。

このガードは stopPropagation() しか呼ばず、preventDefault() は決して呼びません。 IME がまだ使っているキーの既定動作を打ち消すことこそ、入力メソッドと本当に衝突しうる唯一の 操作であり、Pagefind にイベントを渡さないためには伝播を止めるだけで十分です。

検証: ブラウザ自身の IME を動かす

合成イベントで証明できるのは自分のロジックだけです。 new KeyboardEvent('keydown', { isComposing: true }) を投げても、すでに前提として置いた順序を 再生するだけで、ここでは役に立ちません。順序そのものがバグだったからです。

Chrome DevTools Protocol には Input.imeSetComposition があり、Chromium の実際の変換パイプラインを 動かせます。つまり isComposing は手で設定した値ではなく、ブラウザが計算した値になります。

const cdp = await context.newCDPSession(page);
await cdp.send('Input.imeSetComposition', {
text: 'とうきょう', selectionStart: 5, selectionEnd: 5,
});
await cdp.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter',
});

ページ側が観測したのは { key: 'Enter', isComposing: true } で、修正の前提を仮定ではなく 確認できました。同じ手順をリリース版のバンドルと修正版のバンドルに対して実行すると、 変換中の状態は同一で、結果だけが異なりました

バンドル 変換中の状態 確定の Enter
リリース版 isOpen: true, activeIndex: 0, results: 17 検索結果へ遷移した
修正版 isOpen: true, activeIndex: 0, results: 17 その場にとどまった

この方法の穴は WebKit です。 Playwright の WebKit は CDP を公開していないため変換用の API がなく、まさに 50 ミリ秒の枠が存在する理由となっているエンジンが、自動化できない側に 残ります。Safari は実際の IME で手動で確認しました。

あえて手を付けなかったところ

Escape はガードしていません。これは見落としではなく判断です。変換中の Escape は変換を 取り消すためのキーなので、検索ダイアログまで閉じないのが理想ではあります。しかしその ダイアログはネイティブの <dialog> であり、閉じているのはリスナーではなくブラウザ自身の close-watcher です。これを抑えるには変換中のキーに preventDefault() を呼ぶしかなく、 それはこの修正が他のあらゆる場面で避けている唯一の操作です。モーダルが閉じてしまう害は、 IME と衝突する害より小さいので、Escape は今も閉じます。

上流に報告したら同じバグがもう 2 つ見つかった

ここまでの内容はすべて、他人のコンポーネントの欠陥に対する当て木です。そこで Pagefind#1283 として報告し、 #1284 で修正を出しました。そのパッチを 書くために向こうのソースを読んだところ、同じバグの兄弟が 2 つ見つかりました。どちらも 自分は使っていないコンポーネントなので影響は受けていませんでした。pagefind-input は Escape でクエリを消去し、pagefind-modal は同じキーで自分を閉じます。Escape は、IME の 利用者が変換を取り消すために押すキーです。

その報告でいちばん役に立ったのは、上の CDP の手順でした。日本語 IME を入れなくても、 メンテナが CJK の入力バグを再現できるからです。

まとめ

  • このサイトの検索はベンダーの Web コンポーネントなので、修正はハンドラ内の条件には できませんでした。document のキャプチャフェーズのリスナーはターゲットフェーズのどの リスナーよりも先に走り、そこでの stopPropagation() が、編集できないコードから keydown を 遠ざけます。
  • 報告された症状は、バグの実態より軽く見えていました。 結果が描画された時点で activeIndex0 なので、確定の Enter は早すぎる送信を起こしたのではなく、単語の入力中に ページを移動させていました。
  • isComposing は必要ですが十分ではありません。 WebKit は確定の keydown より先に compositionend を発火するため、確定の Enter の時点でフラグはすでに false です。 compositionstartcompositionend を追えばこれを補え、非推奨の keyCode === 229 を、 狭めるのではなく広げる形で置き換えられます。
  • IME がまだ使っている可能性のあるキーには、preventDefault() ではなく stopPropagation() を。
  • 検証はブラウザ自身の変換パイプラインで行うこと。 手で組み立てた KeyboardEvent では イベント順序のバグは検証できません。組み立てる時点でその順序を仮定しているからです。

参考リンク

この記事をシェア