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

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

- Source: https://oharu121.com/zh-tw/blog/pagefind-ime-composition-capture-phase-iscomposing-webkit/
- Published: 2026-08-15T23:03:15+09:00
- Tags: Pagefind, 國際化, Web API

---
## 引言

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

打日文就是透過 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 網站上一樣適用。但是
**屬性判斷必須寫在處理器裡，而那個處理器不是我能編輯的。**

以下是關鍵的分支，取自廠商的原始碼：

```ts title="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。答案不是推論
出來的，而是直接問了執行中的頁面：

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

**結果一算繪出來，`activeIndex` 就已經是 `0`。** Pagefind 的 `input` 事件在組字期間就會觸發，
所以下拉選單會以打到一半的讀音打開，在使用者還沒選任何漢字之前就把第一筆結果標了起來。
要走到這個危險狀態，連方向鍵都不需要。原本要給 IME 的 Enter 走進第一個分支，然後就跳頁了。

拿這段去對上線中的網站跑，確認了它是真的會發生而不是紙上談兵：組字輸入 `とうきょう`
之後送出確認用的 Enter，瀏覽器就從 `/ja/search` 移動到一篇談語言模型誕生的文章。也就是
開頭我被帶過去的那條路徑。方向鍵讓情況更糟，因為那正是使用者用來瀏覽 IME 候選字的按鍵，
而 Pagefind 會拿它們去移動自己的選取項目，於是在挑字的同時，被標記的搜尋結果也跟著飄移。

*Figure — CaptureInterception: 廠商的監聽器位在 input 的目標階段，在一份我無法編輯的打包檔裡。捕獲是由上往下進行，
    因此 `document` 上的監聽器會先看到同一個 keydown。*

## 第一次嘗試：屬性判斷，然後是一則棄用提示

要在事件抵達一個不屬於你的監聽器之前把它攔下來，機制就是捕獲階段。
**捕獲會從 `document` 一路往下到目標的父層，比任何目標階段的監聽器都早**，
所以位在 input 之上的捕獲監聽器會先看到 keydown，在那裡呼叫 `stopPropagation()`，
廠商的監聽器就收不到了。這個形狀是代理提出的，第一版也是它寫的，以 `isComposing` 為主，
再加上多數 IME 相關資料都會一併建議的 `keyCode === 229` 判斷。

`pnpm check` 除了一行之外都是乾淨的：

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

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

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

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

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

*Figure — EventOrder: 同一次按鍵，兩種順序。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` 是由瀏覽器算出來的，而不是手動設定的：

```js
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](https://github.com/Pagefind/pagefind/issues/1283) 回報，並在
[#1284](https://github.com/Pagefind/pagefind/pull/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`。追蹤 `compositionstart`
  與 `compositionend` 可以補上這一段，同時也用更廣而非更窄的方式，取代已被棄用的
  `keyCode === 229` 判斷。
- 對任何 IME 可能還在使用的按鍵，都要用 **`stopPropagation()`，絕不用 `preventDefault()`**。
- **用瀏覽器自己的組字流程來驗證。** 手工組出來的 `KeyboardEvent` 無法驗證事件順序的 bug，
  因為你在組出它的當下就已經假定了那個順序。

## 參考連結

- [MDN: KeyboardEvent.isComposing（包含 compositionend 之後即為 false 的說明）](https://developer.mozilla.org/zh-TW/docs/Web/API/KeyboardEvent/isComposing)
- [MDN: Element 的 keydown 事件](https://developer.mozilla.org/zh-TW/docs/Web/API/Element/keydown_event)
- [Square 對組字事件的說明（記錄了 Safari 會在 keyDown 之前觸發 compositionEnd）](https://developer.squareup.com/blog/understanding-composition-browser-events/)
- [Chrome DevTools Protocol: Input.imeSetComposition](https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-imeSetComposition)
- [Pagefind#1283（含 CDP 重現步驟的上游回報）](https://github.com/Pagefind/pagefind/issues/1283)
