Tags
The pagefind compass mark, an ellipse enclosing a needle, beside the lowercase pagefind wordmark in white on a near-black card

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.

On this page

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:

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:

{ 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.

A capture-phase listener stops the keydown above the vendor handlerThe event path runs downward from document, through the pagefind-searchbox element, to the input. The IME guard listens on document during the capture phase, which reaches it before the input. Pagefind listens on the input itself at target phase, inside a minified bundle that cannot be edited. When the keydown belongs to a composition the guard calls stopPropagation, cutting the path so the event never reaches the vendor listener.Capture phase, downwarddocumentIME guard listens hereMine. Runs before the target.stopPropagation()<pagefind-searchbox><input>keydown, target phasePagefind listens hereMinified bundle. Not mine to edit.never arrives

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:

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.

Event order for the Enter that commits an IME conversionTwo timelines compared. In Chromium and Gecko the committing Enter fires keydown first, with isComposing true, then compositionend, then keyup. In WebKit compositionend fires first, so the keydown for the same keystroke reports isComposing false, and a guard relying on that property alone misses it. A tail window measured from compositionend covers the WebKit case.Chromium / GeckokeydownisComposing: truecompositionendkeyupThe keydown arrives while the composition is still open, so isComposing is true.WebKitcompositionendkeydownisComposing: falsekeyup50 ms tail window closes this gapcompositionend has already fired, so the same keydown reports false.

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:

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 with a fix in #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 KeyboardEvents cannot test an event-ordering bug, because you have to assume the ordering to write them.

References

Share this article