diff --git a/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx b/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx
index 4ab1920add0..b5325a9205c 100644
--- a/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx
+++ b/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx
@@ -97,6 +97,64 @@ describe('', () => {
expect(input.value).toBe('beta');
});
+ it('ignores hidden-input autofill when readOnly', async () => {
+ const onValueChange = vi.fn();
+ await render(
+
+
+
+
+
+
+
+ alpha
+ beta
+
+
+
+
+
+ ,
+ );
+
+ const hidden = screen.getByRole('textbox', { hidden: true });
+ fireEvent.change(hidden, { target: { value: 'beta' } });
+ await flushMicrotasks();
+
+ const input = screen.getByTestId('input');
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(input.value).toBe('');
+ });
+
+ it('ignores hidden-input autofill when disabled', async () => {
+ const onValueChange = vi.fn();
+ await render(
+
+
+
+
+
+
+
+ alpha
+ beta
+
+
+
+
+
+ ,
+ );
+
+ const hidden = screen.getByRole('textbox', { hidden: true });
+ fireEvent.change(hidden, { target: { value: 'beta' } });
+ await flushMicrotasks();
+
+ const input = screen.getByTestId('input');
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(input.value).toBe('');
+ });
+
it('should pass autoComplete to the visible input', async () => {
await render(
diff --git a/packages/react/src/combobox/root/AriaCombobox.tsx b/packages/react/src/combobox/root/AriaCombobox.tsx
index ccc46e19a30..4a89b82e032 100644
--- a/packages/react/src/combobox/root/AriaCombobox.tsx
+++ b/packages/react/src/combobox/root/AriaCombobox.tsx
@@ -41,7 +41,7 @@ import { createCollatorItemFilter, createSingleSelectionCollatorFilter } from '.
import { useCoreFilter } from './utils/useFilter';
import { useTransitionStatus } from '../../internals/useTransitionStatus';
import { useOpenInteractionType } from '../../utils/useOpenInteractionType';
-import { HTMLProps } from '../../internals/types';
+import type { BaseUIEvent, HTMLProps } from '../../internals/types';
import { useValueChanged } from '../../internals/useValueChanged';
import { NOOP } from '../../internals/noop';
import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups';
@@ -1258,9 +1258,11 @@ export function AriaCombobox(
(inputRef.current || triggerElement)?.focus();
},
// Handle browser autofill.
- onChange(event: React.ChangeEvent) {
+ onChange(event: BaseUIEvent>) {
// Workaround for https://github.com/facebook/react/issues/9023
- if (event.nativeEvent.defaultPrevented) {
+ if (event.nativeEvent.defaultPrevented || disabled || readOnly) {
+ // Outside Field.Root, the event is not wrapped by mergeProps.
+ event.preventBaseUIHandler?.();
return;
}
diff --git a/packages/react/src/combobox/root/ComboboxRoot.test.tsx b/packages/react/src/combobox/root/ComboboxRoot.test.tsx
index c75dee895f8..0a62fbfc247 100644
--- a/packages/react/src/combobox/root/ComboboxRoot.test.tsx
+++ b/packages/react/src/combobox/root/ComboboxRoot.test.tsx
@@ -1456,6 +1456,75 @@ describe('', () => {
});
});
+ it.each([
+ { lockState: 'readOnly', label: 'inside Field', withField: true },
+ { lockState: 'disabled', label: 'inside Field', withField: true },
+ { lockState: 'readOnly', label: 'outside Field', withField: false },
+ { lockState: 'disabled', label: 'outside Field', withField: false },
+ ] as const)('ignores hidden-input autofill when $lockState $label', async ({
+ lockState,
+ withField,
+ }) => {
+ const onValueChange = vi.fn();
+ const onInputValueChange = vi.fn();
+ const combobox = (
+
+
+
+
+
+
+ a
+ b
+
+
+
+
+
+ );
+
+ await render(
+ withField ? (
+
+ ) : (
+ combobox
+ ),
+ );
+
+ const visibleInput = screen.getByTestId('input');
+ const hiddenInput = screen
+ .getAllByDisplayValue('')
+ .find((el) => el.getAttribute('name') === 'test') as HTMLInputElement;
+ expect(hiddenInput).not.toBeUndefined();
+
+ if (withField) {
+ expect(screen.getByTestId('error')).toHaveTextContent('test');
+ }
+
+ fireEvent.change(hiddenInput, { target: { value: 'b' } });
+ await flushMicrotasks();
+
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(onInputValueChange).not.toHaveBeenCalled();
+ expect(visibleInput.value).toBe('');
+ expect(hiddenInput.value).toBe('');
+
+ if (withField) {
+ expect(screen.getByTestId('error')).toHaveTextContent('test');
+ }
+ });
+
it('shows all items when opening after browser autofill', async () => {
const items = ['a', 'b', 'c'];
const { user } = await render(
diff --git a/packages/react/src/number-field/root/NumberFieldRoot.test.tsx b/packages/react/src/number-field/root/NumberFieldRoot.test.tsx
index dbc2b8d768b..9c9f425db5d 100644
--- a/packages/react/src/number-field/root/NumberFieldRoot.test.tsx
+++ b/packages/react/src/number-field/root/NumberFieldRoot.test.tsx
@@ -1320,6 +1320,62 @@ describe('', () => {
expect(onValueChange.mock.calls[0][0]).toBe(42);
expect(input).toHaveValue('42');
});
+
+ it.each([
+ { lockState: 'readOnly', label: 'inside Field', withField: true },
+ { lockState: 'disabled', label: 'inside Field', withField: true },
+ { lockState: 'readOnly', label: 'outside Field', withField: false },
+ { lockState: 'disabled', label: 'outside Field', withField: false },
+ ] as const)(
+ 'ignores hidden-input autofill when $lockState $label',
+ async ({ lockState, withField }) => {
+ const onValueChange = vi.fn();
+ const numberField = (
+
+
+
+ );
+
+ await render(
+ withField ? (
+
+ ) : (
+ numberField
+ ),
+ );
+
+ const input = screen.getByRole('textbox');
+ const hiddenInput = document.querySelector(
+ 'input[type="number"][name="quantity"]',
+ ) as HTMLInputElement;
+
+ expect(hiddenInput).not.toBe(null);
+
+ if (withField) {
+ expect(screen.getByTestId('error')).toHaveTextContent('test');
+ }
+
+ fireEvent.change(hiddenInput, { target: { value: '42' } });
+
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(input).toHaveValue('1');
+
+ if (withField) {
+ expect(screen.getByTestId('error')).toHaveTextContent('test');
+ }
+ },
+ );
});
describe('Field', () => {
diff --git a/packages/react/src/number-field/root/NumberFieldRoot.tsx b/packages/react/src/number-field/root/NumberFieldRoot.tsx
index 9a40b9809c2..83a12e505ef 100644
--- a/packages/react/src/number-field/root/NumberFieldRoot.tsx
+++ b/packages/react/src/number-field/root/NumberFieldRoot.tsx
@@ -15,7 +15,7 @@ import { InputMode, NumberFieldRootContext } from './NumberFieldRootContext';
import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext';
import type { FieldRootState } from '../../field/root/FieldRoot';
import { useLabelableId } from '../../internals/labelable-provider/useLabelableId';
-import type { BaseUIComponentProps } from '../../internals/types';
+import type { BaseUIComponentProps, BaseUIEvent } from '../../internals/types';
import { stateAttributesMapping } from '../utils/stateAttributesMapping';
import { useRenderElement } from '../../internals/useRenderElement';
import {
@@ -462,9 +462,11 @@ export const NumberFieldRoot = React.forwardRef(function NumberFieldRoot(
onFocus() {
inputRef.current?.focus();
},
- onChange(event) {
+ onChange(event: BaseUIEvent>) {
// Workaround for https://github.com/facebook/react/issues/9023
- if (event.nativeEvent.defaultPrevented) {
+ if (event.nativeEvent.defaultPrevented || disabled || readOnly) {
+ // Outside Field.Root, the event is not wrapped by mergeProps.
+ event.preventBaseUIHandler?.();
return;
}
diff --git a/packages/react/src/select/root/SelectRoot.test.tsx b/packages/react/src/select/root/SelectRoot.test.tsx
index 589a7dc1cd8..52cddecf849 100644
--- a/packages/react/src/select/root/SelectRoot.test.tsx
+++ b/packages/react/src/select/root/SelectRoot.test.tsx
@@ -733,6 +733,68 @@ describe('', () => {
});
});
+ it.each([
+ { lockState: 'readOnly', label: 'inside Field', withField: true },
+ { lockState: 'disabled', label: 'inside Field', withField: true },
+ { lockState: 'readOnly', label: 'outside Field', withField: false },
+ { lockState: 'disabled', label: 'outside Field', withField: false },
+ ] as const)('ignores hidden-input autofill when $lockState $label', async ({
+ lockState,
+ withField,
+ }) => {
+ const onValueChange = vi.fn();
+ const select = (
+
+
+
+
+
+
+
+ a
+ b
+
+
+
+
+ );
+
+ await render(
+ withField ? (
+
+ ) : (
+ select
+ ),
+ );
+
+ const selectInput = screen.getByRole('textbox', { hidden: true });
+ expect(selectInput).toHaveAttribute('name', 'select');
+
+ if (withField) {
+ expect(screen.getByTestId('error')).toHaveTextContent('test');
+ }
+
+ fireEvent.change(selectInput, { target: { value: 'b' } });
+ await flushMicrotasks();
+
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(selectInput.value).toBe('');
+
+ if (withField) {
+ expect(screen.getByTestId('error')).toHaveTextContent('test');
+ }
+ });
+
describe('prop: modal', () => {
it('should render an internal backdrop when `true`', async () => {
const { user } = await render(
diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx
index 159fc5f639e..e77e0a99350 100644
--- a/packages/react/src/select/root/SelectRoot.tsx
+++ b/packages/react/src/select/root/SelectRoot.tsx
@@ -34,6 +34,7 @@ import { useFormContext } from '../../internals/form-context/FormContext';
import { type Group, stringifyAsLabel, stringifyAsValue } from '../../internals/resolveValueLabel';
import { defaultItemEquality, findItemIndex } from '../../internals/itemEquality';
import { useValueChanged } from '../../internals/useValueChanged';
+import type { BaseUIEvent } from '../../internals/types';
import { useOpenInteractionType } from '../../utils/useOpenInteractionType';
import { getMaxScrollOffset, normalizeScrollOffset } from '../../utils/scrollEdges';
import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups';
@@ -559,9 +560,11 @@ export function SelectRoot(
});
},
// Handle browser autofill.
- onChange(event: React.ChangeEvent) {
+ onChange(event: BaseUIEvent>) {
// Workaround for https://github.com/facebook/react/issues/9023
- if (event.nativeEvent.defaultPrevented) {
+ if (event.nativeEvent.defaultPrevented || disabled || readOnly) {
+ // Outside Field.Root, the event is not wrapped by mergeProps.
+ event.preventBaseUIHandler?.();
return;
}