diff --git a/.changeset/fix-select-spurious-onvaluechange.md b/.changeset/fix-select-spurious-onvaluechange.md new file mode 100644 index 000000000..af04714ee --- /dev/null +++ b/.changeset/fix-select-spurious-onvaluechange.md @@ -0,0 +1,15 @@ +--- +"@radix-ui/react-select": patch +--- + +fix(select): prevent spurious onValueChange calls with empty string in forms + +When a controlled Select component inside a form receives an external value +update (e.g., from form.reset()), onValueChange was incorrectly being called +with an empty string, which would overwrite the valid controlled value. + +This fix guards the internal onChange handler to skip empty value updates when +a valid controlled value already exists, ensuring onValueChange only fires for +genuine user interactions or form autofill scenarios. + +Fixes #3135, #3249, #3693 diff --git a/apps/storybook/stories/select.stories.tsx b/apps/storybook/stories/select.stories.tsx index 7e9cd1007..1f375d85c 100644 --- a/apps/storybook/stories/select.stories.tsx +++ b/apps/storybook/stories/select.stories.tsx @@ -923,6 +923,84 @@ export const Cypress = () => { ); }; +/** + * This story demonstrates the fix for #3135: Controlled Select in a form + * should not call onValueChange with empty string when value is updated externally. + * + * Steps to verify: + * 1. Click "Set to Apple" - value should change to "apple" and onValueChange count stays at 1 + * 2. Before fix: onValueChange would be called twice (once with "apple", once with "") + * 3. After fix: onValueChange is only called once with "apple" + */ +export const ControlledInFormExternalUpdate = () => { + const [value, setValue] = React.useState(''); + const [changeCount, setChangeCount] = React.useState(0); + const [lastValue, setLastValue] = React.useState(''); + + const handleValueChange = (newValue: string) => { + setChangeCount((c) => c + 1); + setLastValue(newValue); + setValue(newValue); + }; + + return ( +
+
+ Test for #3135 fix: +

onValueChange call count: {changeCount}

+

Last onValueChange value: "{lastValue}"

+

Current value: "{value}"

+
+ +
+ + + +
+ + +
+ ); +}; + type PaddedElement = 'content' | 'viewport'; interface ChromaticSelectProps extends React.ComponentProps { diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 01f51923e..0477152a1 100644 --- a/packages/react/select/src/select.tsx +++ b/packages/react/select/src/select.tsx @@ -225,7 +225,12 @@ const Select: React.FC = (props: ScopedProps) => { autoComplete={autoComplete} value={value} // enable form autofill - onChange={(event) => setValue(event.target.value)} + onChange={(event) => { + const newValue = event.target.value; + if (newValue || !value) { + setValue(newValue); + } + }} disabled={disabled} form={form} >