標籤
近黑色卡片上的白色 pagefind 標誌與小寫字樣,標誌是一個包著指針的橢圓形

在捕獲階段攔下 Pagefind 的 Enter,修好日文搜尋 — 為什麼只靠 isComposing 不夠

輸入法組字還沒結束,Pagefind 就把頁面帶走了。isComposing 是必要條件但不充分:WebKit 會在 keydown 之前先觸發 compositionend。

本頁目錄

引言

我想在自己的部落格上搜尋「東京」,結果被帶到一篇談語言模型歷史的文章。

打日文就是透過 IME 打字。要打出那個詞,得先輸入 toukyou,IME 會顯示帶底線的讀音 とうきょう,接著按 Space 叫出候選字,再按 Enter 確認要的那一個。這些按鍵同樣會傳到頁面上, 所以對 IME 來說代表「就是這個漢字」的 Enter,對搜尋框來說是「執行」。我在 React 遇過同樣的 問題,當時在條件裡加上 e.nativeEvent.isComposing 就解決了,因此我以為這次也是同樣的一行修正。

並不是,有兩個原因。搜尋框屬於 Pagefind,不屬於我,所以根本沒有一個屬於我的條件可以加。 而且光靠 isComposing 補不起這個缺口,因為 WebKit 會在結束組字的那個 keydown 之前 先觸發 compositionend。本文整理這個 bug 實際上是什麼、為什麼那個看似理所當然的屬性判斷 是必要卻不充分的,以及修正最後為什麼會變成一個位於廠商打包檔之上的捕獲階段監聽器。

搜尋框不屬於我

這個網站的搜尋是 Pagefind 自己的 <pagefind-searchbox> Web 元件。我只是傳屬性給它, 再用自訂屬性套上佈景主題,我的參與就只有這些。它的鍵盤處理位在 pagefind --site dist 產生到 dist/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 除了一行之外都是乾淨的:

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 觀察到。這兩個事件比 isComposing 更早存在,而且在所有會組字的引擎上都會觸發。代理以這兩個 事件為主重寫了防護。提示消失了,涵蓋範圍反而變廣,因為組字事件也能抓到 keyCode 判斷 永遠抓不到的引擎。

真正有效的做法:自己追蹤組字狀態

最終的防護在下列三個條件任一成立時,就把該次 keydown 視為組字的一部分,因為單獨任何一個 都不完整:

訊號 涵蓋的情況
event.isComposing 一般情況,Chromium 與 Gecko 組字進行中
收到 compositionstart 但還沒收到 compositionend 在組字按鍵上不設定 isComposing 的引擎
compositionend 之後 50 毫秒內的 keydown WebKit,它會在結束用的 keydown 之前觸發 compositionend

讓我意外的是第三列。在 Chromium 與 Gecko 上,確認用的 Enter 依序送出 keydown、 compositionend、keyup,所以 keydown 當下 isComposingtrue,一切正常。 WebKit 把前兩者對調,等到同一個 Enter 的 keydown 抵達時,組字在形式上已經結束, isComposing 也已經是 false。只靠這個屬性寫出來的防護,會通過 Chrome 上的每一項測試, 然後在 Safari 上失敗。

確認 IME 組字的 Enter 事件順序兩條時間軸的比較。在 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。

同一次按鍵,兩種順序。WebKit 在同一個 Enter 的 keydown 之前就結束了組字, 所以防護想檢查的那個屬性早已變成 false。

這個 50 毫秒的視窗會在下一次 keyup 關閉,所以刻意按下的第二次 Enter 不會被吞掉。 另外有兩條小規則,用來避免旗標卡住。一是在 focusout 時清掉它,因為使用者點到別處而放棄的 組字永遠不會收到 compositionend。二是這個清除動作要跑在「是不是在搜尋框裡」這個判斷之前。 這個順序之所以重要,是因為已經從 DOM 中移除的元素會把 closest() 一起帶走,而一個卡在 true 的旗標,會在該頁面存活的期間內默默吞掉每一個受管的按鍵。

這個防護只會呼叫 stopPropagation(),絕不呼叫 preventDefault() 取消一個 IME 還在使用中的按鍵的預設行為,才是唯一真正可能和輸入法打架的動作,而要讓事件 不流到 Pagefind,光是停止傳遞就已經足夠。

驗證:從瀏覽器驅動真正的 IME

合成事件能證明的只有你自己的邏輯。 送出 new KeyboardEvent('keydown', { isComposing: true }) 只是把你早已假定的順序重播一次, 在這裡毫無用處,因為順序本身就是那個 bug。

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 我改用 真正的輸入法手動確認。

刻意不去動的部分

Escape 沒有納入防護,這是判斷而不是疏漏。組字期間的 Escape 應該用來取消轉換,理想上 不該連搜尋對話框一起關掉。但那個對話框是原生的 <dialog>,關掉它的是瀏覽器自己的 close-watcher 而不是某個監聽器,要壓下它就得對一個組字中的按鍵呼叫 preventDefault(), 而那正是這次修正在其他每個地方都避開的動作。失去一個對話框,比和 IME 打架的傷害小, 所以 Escape 現在仍然會把它關掉。

回報到上游時,又找到兩個同樣的 bug

上面這一切,都是在別人的元件缺陷之上做的權宜做法。因此我以 Pagefind#1283 回報,並在 #1284 提出修正。為了寫那份修補而讀他們的 原始碼時,又翻出了兩個同樣的 bug 的兄弟,只是因為我沒用到那些元件所以從未受影響: pagefind-input 會在 Escape 時清掉整段查詢,而 pagefind-modal 會在同一個按鍵下把自己關掉。 Escape 正是 IME 使用者用來取消轉換的按鍵。

那份回報裡最有用的,反而是上面那段 CDP 流程,因為它讓維護者不必安裝日文輸入法, 也能重現 CJK 的輸入問題。

總結

  • 這個網站的搜尋是廠商的 Web 元件,所以修正沒辦法是處理器裡的一個條件。document 上的 捕獲階段監聽器會比任何目標階段的監聽器都早執行,在那裡呼叫 stopPropagation(), 就能讓 keydown 遠離我無法編輯的程式碼。
  • 回報出來的症狀比實際的 bug 輕。 結果一算繪出來 activeIndex 就是 0,所以確認用的 Enter 不只是提早送出,而是在打字打到一半時把頁面帶走。
  • isComposing 是必要條件,但不充分。 WebKit 會在結束用的 keydown 之前觸發 compositionend,所以確認用的 Enter 到達時旗標已經是 false。追蹤 compositionstartcompositionend 可以補上這一段,同時也用更廣而非更窄的方式,取代已被棄用的 keyCode === 229 判斷。
  • 對任何 IME 可能還在使用的按鍵,都要用 stopPropagation(),絕不用 preventDefault()
  • 用瀏覽器自己的組字流程來驗證。 手工組出來的 KeyboardEvent 無法驗證事件順序的 bug, 因為你在組出它的當下就已經假定了那個順序。

參考連結

分享這篇文章