# How I made Japanese search work by intercepting Pagefind's Enter key in the capture phase

> Pagefind navigated away while an IME conversion was open. isComposing is necessary but not sufficient: WebKit fires compositionend before the keydown.

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

---
## Introduction

I went to search my own blog for 東京 and ended up on an article about the
history of language models.

Typing Japanese means typing through an IME. Reaching that word takes `toukyou`,
which the IME shows as the reading とうきょう underlined, then Space to offer
conversion candidates, then Enter to commit the right one. Every one of those
keystrokes also reaches the page, so the Enter that means "yes, that kanji" to
the IME means "go" to a search box. I had hit this before in React and fixed it
by adding `e.nativeEvent.isComposing` to the condition, so I assumed this was
the same one-line fix.

It was not, for two reasons. **The search box belongs to Pagefind, not to me**,
so there was no condition of mine to add anything to. And **`isComposing` alone
does not close the hole**, because WebKit fires `compositionend` *before* the
keydown that ended the composition. This article covers what the bug actually
turned out to be, why the obvious property check is necessary but not
sufficient, and how the fix ended up as a capture-phase listener above a vendor
bundle.

## The search box is not mine

Search on this site is Pagefind's own `<pagefind-searchbox>` web component.
I pass it attributes and theme it with custom properties, and that is the whole
of my involvement. Its keyboard handling lives in the minified bundle that
`pagefind --site dist` drops into `dist/pagefind/`.

That single fact rules out the React-shaped fix. **`isComposing` is a native DOM
property, not a React invention**, so `e.nativeEvent.isComposing` in React is
literally this same property and it applies to a plain Astro site unchanged. But
**a property check has to go inside a handler, and the handler was not mine to
edit.**

Here is the branch that matters, from the vendor source:

```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);
      }
      // …
  }
});
```

No composition check anywhere in it.

## activeIndex was already 0, so Enter navigated

My working theory was a stray submit: annoying, recoverable, the kind of thing
where you press Escape and try again. Reading `activateCurrentSelection()`
changed that, because it ends in `window.location.href = …`. So the question
became whether `activeIndex` could realistically be at or above zero while
someone was still composing, and the answer came from asking the live page
rather than reasoning about it:

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

**`activeIndex` is already `0` the moment results render.** Pagefind's `input`
event fires during composition, so the dropdown opens on the half-typed reading
and highlights the first hit before the user has chosen any kanji. No arrow key
is needed to reach the dangerous state. The Enter meant for the IME takes the
first branch and navigates.

Running that against the deployed site confirmed it was live rather than
theoretical: composing `とうきょう`, then a committing Enter, moved the browser
from `/ja/search` to an article about the birth of language models. Which is how
I got there in the first place. The arrow keys make it worse, because they are
how you walk an IME candidate list and Pagefind consumes them to move its own
selection, so the highlighted result drifts while you are choosing characters.

*Figure — CaptureInterception: The vendor's listener sits on the input at target phase, inside a bundle I
    cannot edit. A listener on `document` sees the same keydown first, because
    capture runs top-down.*

## First attempt: the property check, then a deprecation hint

The mechanism for stopping an event before it reaches a listener you do not own
is the capture phase. **Capture descends from `document` to the target's parent
before any target-phase listener runs**, so a capture listener above the input
sees the keydown first, and `stopPropagation()` there means the vendor listener
never receives it. The agent proposed that shape and wrote the first
version around `isComposing`, plus the `keyCode === 229` test that most IME
guidance recommends alongside it.

`pnpm check` was clean except for one line:

```text
src/scripts/pagefind-ime-guard.ts:50:33 - warning ts(6385): 'keyCode' is deprecated.

  if (event.isComposing || event.keyCode === 229) return true;
```

The totals read `0 errors, 0 warnings, 1 hint`. This repo treats hints as things
to read rather than things to pass, so I asked whether a non-deprecated
alternative existed for this case, expecting the answer to be no and to accept
the hint.

**There was no replacement property, but there was a better mechanism.**
`keyCode === 229` is a proxy for "the engine is composing but did not set
`isComposing`", and that state is directly observable with `compositionstart`
and `compositionend`, which predate `isComposing` and fire in every engine that
composes at all. The agent rewrote the guard around those two events. The hint
went away, and coverage went **up** rather than down, since composition events
also catch engines the `keyCode` test never would.

## What actually worked: tracking the composition myself

The final guard treats a keydown as part of a composition when any of three
things is true, because no single one of them is complete:

| Signal | Catches |
| --- | --- |
| `event.isComposing` | The ordinary case, Chromium and Gecko mid-composition |
| A `compositionstart` seen with no `compositionend` yet | Engines that leave `isComposing` unset on a composing key |
| A keydown within 50 ms of `compositionend` | WebKit, which fires `compositionend` *before* the closing keydown |

That third row is the one that surprised me. In Chromium and Gecko the
committing Enter arrives as keydown, then `compositionend`, then keyup, so
`isComposing` is `true` on the keydown and everything works. **WebKit reverses
the first two**, so by the time the keydown for that same Enter arrives, the
composition is formally over and `isComposing` is already `false`. A guard built
on the property alone passes every test in Chrome and fails in Safari.

*Figure — EventOrder: One keystroke, two orders. WebKit retires the composition before the keydown
    for the same Enter, so the property a guard would check has already gone
    false.*

**The 50 ms window is closed again on the next keyup**, so a deliberate second
Enter is never swallowed. Two smaller rules keep the flag from getting stuck: it is
cleared on `focusout`, since a composition abandoned by clicking away never gets
its `compositionend`, and the clear runs before the "is this inside the search
box" test rather than after it. That ordering matters because an element removed
from the document takes `closest()` with it, and a flag left stuck `true` would
silently swallow every guarded key for the life of the page.

**The guard only ever calls `stopPropagation()`, never `preventDefault()`.**
Cancelling the default action of a key the IME is still using is the one move
that can genuinely fight the input method, and stopping propagation is enough to
keep the event away from Pagefind.

## Verification: driving a real IME from the browser

**Synthetic events prove your own logic and nothing else.** Dispatching
`new KeyboardEvent('keydown', { isComposing: true })` only replays the ordering
already assumed, which is useless here, since the ordering *was* the bug.

Chrome DevTools Protocol has `Input.imeSetComposition`, which drives Chromium's
real composition pipeline, so `isComposing` gets computed by the browser instead
of set by hand:

```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',
});
```

The page observed `{ key: 'Enter', isComposing: true }`, which confirms the
fix's premise rather than assuming it. Running the same sequence against the
released bundle and the patched one gave **identical mid-composition state and
different outcomes**:

| Bundle | Mid-composition state | Committing Enter |
| --- | --- | --- |
| Released | `isOpen: true, activeIndex: 0, results: 17` | Navigated to a result |
| Patched | `isOpen: true, activeIndex: 0, results: 17` | Stayed put |

**WebKit is the gap in this method.** Playwright's WebKit exposes no CDP, so
there is no composition API for it, which leaves the engine whose event ordering
the 50 ms window exists for as the one that cannot be automated. I checked
Safari by hand with a real IME instead.

## What I deliberately left alone

Escape is not guarded, and that is a decision rather than an oversight. During a
composition Escape should revert the conversion, so ideally it would not also
close the search dialog. But that dialog is a native `<dialog>` closed by the
browser's own close-watcher rather than by a listener, so suppressing it would
require `preventDefault()` on a composing key, which is the one thing this fix
avoids everywhere else. **Losing a modal is a smaller harm than fighting the
IME**, so Escape still closes it.

## Reporting it upstream found two more of the same bug

Everything above is a shim over a defect in someone else's component, so it went
upstream as [Pagefind#1283](https://github.com/Pagefind/pagefind/issues/1283)
with a fix in [#1284](https://github.com/Pagefind/pagefind/pull/1284). Reading
their source to write that patch turned up **two siblings of the same bug** that
had never affected me, because I do not use those components: `pagefind-input`
clears the query on Escape, and `pagefind-modal` dismisses itself on the same
key. Escape is what an IME user presses to cancel a conversion.

The CDP recipe above turned out to be the most useful thing in that report,
since it lets a maintainer reproduce a CJK input bug without installing a
Japanese IME.

## Summary

- **Search on this site is a vendor web component**, so the fix could not be a
  condition inside a handler. A capture-phase listener on `document` runs before
  any target-phase listener, and `stopPropagation()` there keeps the keydown away
  from code I cannot edit.
- **The reported symptom understated the bug.** `activeIndex` sits at `0` as soon
  as results render, so the committing Enter did not merely submit early, it
  navigated away mid-word.
- **`isComposing` is necessary and not sufficient.** WebKit fires
  `compositionend` before the closing keydown, so the flag is already `false` on
  the Enter that commits. Tracking `compositionstart` and `compositionend`
  covers that, and also replaces the deprecated `keyCode === 229` test with
  wider coverage rather than narrower.
- **`stopPropagation()`, never `preventDefault()`**, on any key an IME might
  still be using.
- **Verify with the browser's own composition pipeline.** Hand-built
  `KeyboardEvent`s cannot test an event-ordering bug, because you have to assume
  the ordering to write them.

## References

- [MDN: KeyboardEvent.isComposing, including the note that it is false once compositionend has fired](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing)
- [MDN: Element keydown event](https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event)
- [Square's write-up on composition events, which documents Safari firing compositionEnd before keyDown](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, the upstream report with the CDP reproduction](https://github.com/Pagefind/pagefind/issues/1283)
