[combobox] Item selected with the mouse during IME composition leaves stale text in the input
- Dominant language
- TypeScript
- Stars
- 10.9k
- Forks
- 543
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 101
Description
# Bug report
## Current behavior
When an IME composition is active in `Combobox.Input` (typing Korean/Japanese/Chinese), selecting an item **with the mouse** commits the value but leaves the input showing the in-progress composition text.
```
type "창" with a Korean IME (composition still active, not yet committed)
click the "창세기" item
→ onValueChange fires, selected value === "창세기" ✅
→ input still displays "창" ❌
```
To the user this reads as "the click didn't register". The natural next action is to delete the leftover text and try again — and because emptying the input clears the selection (`combobox/input/ComboboxInput.js:272`, `setSelectedValue(null)`), a required field in a form then reports *no selection at all*. So a display-only glitch turns into real data loss.
Keyboard selection is unaffected: Enter commits the composition first, so there is no composition left to win over the filled-in label.
This only reproduces through an actual IME composition. Typing the same characters programmatically (or with a non-IME keyboard) does not reproduce it.
## Expected behavior
After an item is pressed, the input shows the selected item's label — the same as when no composition is active.
## Reproducible example
I could not exercise an IME inside CodeSandbox, so here is a standalone minimal app plus a scripted reproduction that drives a real composition through CDP.
`main.jsx` — stock components, no wrappers:
```jsx
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { Combobox } from '@base-ui/react';
const BOOKS = ['창세기', '출애굽기', '레위기', '민수기', '신명기'];
function App() {
const [value, setValue] = useState(null);
return (
{(item) => (
{item}
)}
selected: {String(value)}
);
}
createRoot(document.getElementById('root')).render();
```
**Manual steps** (macOS, Korean 2-Set input): focus the input, type `ㅊ` `ㅏ` `ㅇ` so that `창` is still underlined/composing, then click `창세기` in the popup. The paragraph shows `selected: 창세기` while the input still shows `창`.
**Scripted** (Playwright + CDP `Input.imeSetComposition`, which produces a genuine composition):
```js
const cdp = await context.newCDPSession(page);
await page.locator('#book').click();
for (const s of ['ㅊ', '차', '창']) {
await cdp.send('Input.imeSetComposition', { text: s, selectionStart: s.length, selectionEnd: s.length });
}
await page.locator('[role="option"]', { hasText: '창세기' }).first().click();
console.log(await page.locator('#book').inputValue()); // "창" ← expected "창세기"
console.log(await page.locator('#state').innerText()); // "selected: 창세기"
```
Output I get, with the two non-composing paths for contrast:
```
composition active, mouse click input="창" selected: 창세기 ← mismatch
composition committed, click input="창세기" selected: 창세기
no typing, click input="창세기" selected: 창세기
```
## Base UI version
v1.7.0. Also reproduces on v1.3.0 (where I first hit it).
## Which browser are you using?
Chrome 152.0.7977.65
## Which OS are you using?
macOS 26.6.2
## Which assistive tech are you using (if applicable)?
None.
## Additional context
What I found while tracing it, in case it's useful — line numbers are from v1.7.0.
1. `ComboboxInput` keeps the composing string in local state and renders it in preference to the store value:
```js
// combobox/input/ComboboxInput.js:167
value: composingValue ?? inputValue,
```
`composingValue` is only cleared in `onCompositionEnd` (`:219`).
2. Pressing an item fills only the **store's** input value:
```js
// combobox/root/AriaCombobox.js:562-565
const shouldFillInput = ... || single && !store.state.inputInsidePopup;
if (shouldFillInput) setInputValue(stringifyAsLabel(nextValue, itemToStringLabel), ...);
```
3. `ComboboxItem`'s `onPointerDownCapture` calls `preventDefault()` (`:131`), which is what keeps focus in the input — so the click never ends the composition. `composingValue` is still set when the label lands in the store, and step 1 keeps rendering the stale composition.
So the two mechanisms are individually reasonable; they just collide on pointer selection. The composition guard added in #2942 is clearly deliberate (avoid filtering on partial jamo) and I don't think it's the thing to change.
If it helps, the shape of a fix might be either letting an item press clear `composingValue` (the user has chosen an item, so the in-flight query is moot), or having the fill-on-press path win over `composingValue` specifically for `REASONS.itemPress`. I'm happy to open a PR if you can confirm which direction you'd prefer.
Two smaller notes from the same trace:
- The follow-on severity comes from `:272` — emptying the input clears the selection in single mode. That's sensible on its own, but combined with this bug the user's "let me clear it and retry" reflex silently drops an already-valid selection.
- As a userland workaround, passing `value` to `Combobox.Input` still wins, because element props are merged last (`props: [inputProps, triggerProps, { ... }, validationProps]`). Worth noting that v1.3.0 spelled this out explicitly as `componentProps.value ?? composingValue ?? inputValue` and v1.7.0 no longer does — so the workaround now depends on merge order rather than on anything intentional.
Contributor guide
Research direction
Start in combobox/input/ComboboxInput.js around the composingValue rendering and onCompositionEnd logic, then trace combobox/root/AriaCombobox.js and ComboboxItem's pointer-press handling. Reproduce the issue with the supplied Playwright CDP composition sequence. Done means a mouse selection during active IME composition displays the selected item's label while keyboard selection and non-composing paths remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- playwright, react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100