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.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);
+ });
});
});
diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx
index 5da6a6ccc8..a885673b2e 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,13 +54,16 @@ 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;
+ const [composingValue, setComposingValue] = React.useState(null);
+ const isComposingRef = React.useRef(false);
+
const setInputElement = useEventCallback((element) => {
store.apply({
inputElement: element,
@@ -194,7 +197,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
const trimmed = nextVal.trim();
if (trimmed !== '') {
store.state.setOpen(true, createBaseUIEventDetails('none', event.nativeEvent));
- if (!(selectionMode === 'none' && autoHighlight)) {
+ if (!autoHighlight) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
@@ -207,7 +210,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
if (
open &&
store.state.activeIndex !== null &&
- !(selectionMode === 'none' && autoHighlight && nextVal.trim() !== '')
+ !(autoHighlight && nextVal.trim() !== '')
) {
store.state.setIndices({
activeIndex: null,
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 de884d327f..b8bc56aeab 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';
@@ -1045,13 +1046,15 @@ export function ComboboxRootInternal