Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/fix-select-spurious-onvaluechange.md
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions apps/storybook/stories/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('');
const [changeCount, setChangeCount] = React.useState(0);
const [lastValue, setLastValue] = React.useState<string>('');

const handleValueChange = (newValue: string) => {
setChangeCount((c) => c + 1);
setLastValue(newValue);
setValue(newValue);
};

return (
<form style={{ padding: 50 }}>
<div style={{ marginBottom: 20 }}>
<strong>Test for #3135 fix:</strong>
<p>onValueChange call count: {changeCount}</p>
<p>Last onValueChange value: "{lastValue}"</p>
<p>Current value: "{value}"</p>
</div>

<div style={{ marginBottom: 20 }}>
<button type="button" onClick={() => setValue('apple')} style={{ marginRight: 10 }}>
Set to Apple (external update)
</button>
<button type="button" onClick={() => setValue('banana')} style={{ marginRight: 10 }}>
Set to Banana (external update)
</button>
<button type="button" onClick={() => setValue('')}>
Clear value
</button>
</div>

<Label>
Select a fruit:
<Select.Root value={value} onValueChange={handleValueChange}>
<Select.Trigger className={styles.trigger}>
<Select.Value placeholder="Pick a fruit" />
<Select.Icon />
</Select.Trigger>
<Select.Portal>
<Select.Content className={styles.content}>
<Select.Viewport className={styles.viewport}>
<Select.Item className={styles.item} value="apple">
<Select.ItemText>Apple</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
<Select.Item className={styles.item} value="banana">
<Select.ItemText>Banana</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
<Select.Item className={styles.item} value="orange">
<Select.ItemText>Orange</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</Label>
</form>
);
};

type PaddedElement = 'content' | 'viewport';

interface ChromaticSelectProps extends React.ComponentProps<typeof Select.Trigger> {
Expand Down
7 changes: 6 additions & 1 deletion packages/react/select/src/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,12 @@ const Select: React.FC<SelectProps> = (props: ScopedProps<SelectProps>) => {
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}
>
Expand Down