From 63d507c4e1e2b4284e9a5801862043ffd4a2cf97 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 10 Sep 2025 05:12:32 +1000 Subject: [PATCH 1/4] [combobox][autocomplete] Fix controlled input value updates --- .../autocomplete/value/AutocompleteValue.tsx | 8 +-- .../src/combobox/clear/ComboboxClear.tsx | 5 +- .../src/combobox/input/ComboboxInput.tsx | 65 ++----------------- .../src/combobox/root/ComboboxRootContext.tsx | 8 +++ .../combobox/root/ComboboxRootInternal.tsx | 17 +++-- .../src/combobox/trigger/ComboboxTrigger.tsx | 5 +- 6 files changed, 31 insertions(+), 77 deletions(-) diff --git a/packages/react/src/autocomplete/value/AutocompleteValue.tsx b/packages/react/src/autocomplete/value/AutocompleteValue.tsx index f1e4439a1c..9b2865f271 100644 --- a/packages/react/src/autocomplete/value/AutocompleteValue.tsx +++ b/packages/react/src/autocomplete/value/AutocompleteValue.tsx @@ -1,8 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui-components/utils/store'; -import { useComboboxRootContext } from '../../combobox/root/ComboboxRootContext'; -import { selectors } from '../../combobox/store'; +import { useComboboxInputValueContext } from '../../combobox/root/ComboboxRootContext'; /** * The current value of the autocomplete. @@ -13,9 +11,7 @@ import { selectors } from '../../combobox/store'; export function AutocompleteValue(props: AutocompleteValue.Props) { const { children } = props; - const store = useComboboxRootContext(); - - const inputValue = useStore(store, selectors.inputValue); + const inputValue = useComboboxInputValueContext(); if (typeof children === 'function') { return children(String(inputValue)); diff --git a/packages/react/src/combobox/clear/ComboboxClear.tsx b/packages/react/src/combobox/clear/ComboboxClear.tsx index 8b3245c524..494277a42e 100644 --- a/packages/react/src/combobox/clear/ComboboxClear.tsx +++ b/packages/react/src/combobox/clear/ComboboxClear.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { useStore } from '@base-ui-components/utils/store'; -import { useComboboxRootContext } from '../root/ComboboxRootContext'; +import { useComboboxInputValueContext, useComboboxRootContext } from '../root/ComboboxRootContext'; import type { BaseUIComponentProps, NativeButtonProps } from '../../utils/types'; import { useRenderElement } from '../../utils/useRenderElement'; import { selectors } from '../store'; @@ -45,7 +45,8 @@ export const ComboboxClear = React.forwardRef(function ComboboxClear( const clearRef = useStore(store, selectors.clearRef); const open = useStore(store, selectors.open); const selectedValue = useStore(store, selectors.selectedValue); - const inputValue = useStore(store, selectors.inputValue); + + const inputValue = useComboboxInputValueContext(); let visible = false; if (selectionMode === 'none') { diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx index a98c7113f2..7c95f80a24 100644 --- a/packages/react/src/combobox/input/ComboboxInput.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.tsx @@ -4,7 +4,7 @@ import { useStore } from '@base-ui-components/utils/store'; import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; import { BaseUIComponentProps } from '../../utils/types'; import { useRenderElement } from '../../utils/useRenderElement'; -import { useComboboxRootContext } from '../root/ComboboxRootContext'; +import { useComboboxInputValueContext, useComboboxRootContext } from '../root/ComboboxRootContext'; import { selectors } from '../store'; import { pressableTriggerOpenStateMapping } from '../../utils/popupStateMapping'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; @@ -54,10 +54,10 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( const triggerProps = useStore(store, selectors.triggerProps); const open = useStore(store, selectors.open); const selectedValue = useStore(store, selectors.selectedValue); - const inputValue = useStore(store, selectors.inputValue); - const [composingValue, setComposingValue] = React.useState(null); - const isComposingRef = React.useRef(false); + // `inputValue` can't be placed in the store. + // https://github.com/mui/base-ui/issues/2703 + const inputValue = useComboboxInputValueContext(); const disabled = fieldDisabled || comboboxDisabled || disabledProp; @@ -145,7 +145,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( triggerProps, { type: 'text', - value: componentProps.value ?? composingValue ?? inputValue, + value: inputValue, 'aria-readonly': readOnly || undefined, 'aria-labelledby': labelId, disabled, @@ -163,62 +163,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( fieldControlValidation?.commitValidation(valueToValidate); } }, - onCompositionStart(event) { - isComposingRef.current = true; - setComposingValue(event.currentTarget.value); - }, - onCompositionEnd(event) { - isComposingRef.current = false; - const next = event.currentTarget.value; - setComposingValue(null); - store.state.setInputValue( - next, - createBaseUIEventDetails('input-change', event.nativeEvent), - ); - }, onChange(event: React.ChangeEvent) { - // During IME composition, avoid propagating controlled updates to preserve - // its state. - if (isComposingRef.current) { - const nextVal = event.currentTarget.value; - setComposingValue(nextVal); - - if (nextVal === '' && !openOnInputClick && !hasPositionerParent) { - store.state.setOpen( - false, - createBaseUIEventDetails('input-clear', event.nativeEvent), - ); - } - - if (!readOnly && !disabled) { - const trimmed = nextVal.trim(); - if (trimmed !== '') { - store.state.setOpen(true, createBaseUIEventDetails('none', event.nativeEvent)); - if (!(selectionMode === 'none' && autoHighlight)) { - store.state.setIndices({ - activeIndex: null, - selectedIndex: null, - type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', - }); - } - } - } - - if ( - open && - store.state.activeIndex !== null && - !(selectionMode === 'none' && autoHighlight && nextVal.trim() !== '') - ) { - store.state.setIndices({ - activeIndex: null, - selectedIndex: null, - type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', - }); - } - - return; - } - store.state.setInputValue( event.currentTarget.value, createBaseUIEventDetails('input-change', event.nativeEvent), diff --git a/packages/react/src/combobox/root/ComboboxRootContext.tsx b/packages/react/src/combobox/root/ComboboxRootContext.tsx index 16b4a33712..01a88ea5d9 100644 --- a/packages/react/src/combobox/root/ComboboxRootContext.tsx +++ b/packages/react/src/combobox/root/ComboboxRootContext.tsx @@ -15,6 +15,10 @@ export const ComboboxFloatingContext = React.createContext(undefined); +// `inputValue` can't be placed in the store. +// https://github.com/mui/base-ui/issues/2703 +export const ComboboxInputValueContext = + React.createContext['value']>(''); export function useComboboxRootContext() { const context = React.useContext(ComboboxRootContext) as ComboboxStore | undefined; @@ -45,3 +49,7 @@ export function useComboboxDerivedItemsContext() { } return context; } + +export function useComboboxInputValueContext() { + return React.useContext(ComboboxInputValueContext); +} diff --git a/packages/react/src/combobox/root/ComboboxRootInternal.tsx b/packages/react/src/combobox/root/ComboboxRootInternal.tsx index a10c14297a..6b7617ccb0 100644 --- a/packages/react/src/combobox/root/ComboboxRootInternal.tsx +++ b/packages/react/src/combobox/root/ComboboxRootInternal.tsx @@ -27,6 +27,7 @@ import { ComboboxFloatingContext, ComboboxDerivedItemsContext, ComboboxRootContext, + ComboboxInputValueContext, } from './ComboboxRootContext'; import { selectors, type State as StoreState } from '../store'; import { useOpenChangeComplete } from '../../utils/useOpenChangeComplete'; @@ -1027,13 +1028,15 @@ export function ComboboxRootInternal Date: Wed, 10 Sep 2025 22:25:01 +1000 Subject: [PATCH 2/4] fix: retain componentProps.value --- packages/react/src/combobox/input/ComboboxInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx index 7c95f80a24..96801f6c73 100644 --- a/packages/react/src/combobox/input/ComboboxInput.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.tsx @@ -145,7 +145,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( triggerProps, { type: 'text', - value: inputValue, + value: componentProps.value ?? inputValue, 'aria-readonly': readOnly || undefined, 'aria-labelledby': labelId, disabled, From 9dace8ebc4c97b29255cfd72c4510d0798bfce90 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 11 Sep 2025 02:56:47 +1000 Subject: [PATCH 3/4] fix: restore composition handling --- .../src/combobox/input/ComboboxInput.tsx | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx index 96801f6c73..a970d44b70 100644 --- a/packages/react/src/combobox/input/ComboboxInput.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.tsx @@ -61,6 +61,9 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( const disabled = fieldDisabled || comboboxDisabled || disabledProp; + const [composingValue, setComposingValue] = React.useState(null); + const isComposingRef = React.useRef(false); + const setInputElement = useEventCallback((element) => { store.apply({ inputElement: element, @@ -145,7 +148,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( triggerProps, { type: 'text', - value: componentProps.value ?? inputValue, + value: componentProps.value ?? composingValue ?? inputValue, 'aria-readonly': readOnly || undefined, 'aria-labelledby': labelId, disabled, @@ -163,7 +166,62 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( fieldControlValidation?.commitValidation(valueToValidate); } }, + onCompositionStart(event) { + isComposingRef.current = true; + setComposingValue(event.currentTarget.value); + }, + onCompositionEnd(event) { + isComposingRef.current = false; + const next = event.currentTarget.value; + setComposingValue(null); + store.state.setInputValue( + next, + createBaseUIEventDetails('input-change', event.nativeEvent), + ); + }, onChange(event: React.ChangeEvent) { + // During IME composition, avoid propagating controlled updates to preserve + // its state. + if (isComposingRef.current) { + const nextVal = event.currentTarget.value; + setComposingValue(nextVal); + + if (nextVal === '' && !openOnInputClick && !hasPositionerParent) { + store.state.setOpen( + false, + createBaseUIEventDetails('input-clear', event.nativeEvent), + ); + } + + if (!readOnly && !disabled) { + const trimmed = nextVal.trim(); + if (trimmed !== '') { + store.state.setOpen(true, createBaseUIEventDetails('none', event.nativeEvent)); + if (!autoHighlight) { + store.state.setIndices({ + activeIndex: null, + selectedIndex: null, + type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', + }); + } + } + } + + if ( + open && + store.state.activeIndex !== null && + !(autoHighlight && nextVal.trim() !== '') + ) { + store.state.setIndices({ + activeIndex: null, + selectedIndex: null, + type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', + }); + } + + return; + } + store.state.setInputValue( event.currentTarget.value, createBaseUIEventDetails('input-change', event.nativeEvent), From 284041b8d9dcda8c547ecee0f01a05db5f4cb58e Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 11 Sep 2025 17:55:06 +1000 Subject: [PATCH 4/4] test --- .../src/combobox/input/ComboboxInput.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/react/src/combobox/input/ComboboxInput.test.tsx b/packages/react/src/combobox/input/ComboboxInput.test.tsx index a19d65a55c..7dc5b24705 100644 --- a/packages/react/src/combobox/input/ComboboxInput.test.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.test.tsx @@ -357,5 +357,38 @@ describe('', () => { expect(input.selectionStart).to.equal(input.value.length); expect(input.selectionEnd).to.equal(input.value.length); }); + + it('preserves caret position when controlled and inserting in the middle', async () => { + function Controlled() { + const [value, setValue] = React.useState(''); + return ( + + + + ); + } + + const { user } = await render(); + + const input = screen.getByRole('combobox'); + + await user.type(input, 'abcd'); + expect(input.value).to.equal('abcd'); + + // Move caret left twice to position after "ab" + await user.keyboard('{ArrowLeft}{ArrowLeft}'); + expect(input.selectionStart).to.equal(2); + expect(input.selectionEnd).to.equal(2); + + await user.keyboard('xxx'); + expect(input.value).to.equal('abxxxcd'); + expect(input.selectionStart).to.equal(5); + expect(input.selectionEnd).to.equal(5); + + await user.keyboard('y'); + expect(input.value).to.equal('abxxxycd'); + expect(input.selectionStart).to.equal(6); + expect(input.selectionEnd).to.equal(6); + }); }); });