
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:
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.
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.
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
documentruns before any target-phase listener, andstopPropagation()there keeps the keydown away from code I cannot edit. - The reported symptom understated the bug.
activeIndexsits at0as soon as results render, so the committing Enter did not merely submit early, it navigated away mid-word. isComposingis necessary and not sufficient. WebKit firescompositionendbefore the closing keydown, so the flag is alreadyfalseon the Enter that commits. Trackingcompositionstartandcompositionendcovers that, and also replaces the deprecatedkeyCode === 229test with wider coverage rather than narrower.stopPropagation(), neverpreventDefault(), 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
- MDN: KeyboardEvent.isComposing, including the note that it is false once compositionend has fired
- MDN: Element keydown event
- Square’s write-up on composition events, which documents Safari firing compositionEnd before keyDown
- Chrome DevTools Protocol: Input.imeSetComposition
- Pagefind#1283, the upstream report with the CDP reproduction