From 2b68bf3efc73b8a99b8af29b7d27735ddedfedfa Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 27 May 2026 16:09:17 +0800 Subject: [PATCH] [checkbox][switch][slider] Fix extra `validate` fn calls (#4911) --- .../src/checkbox/root/CheckboxRoot.test.tsx | 15 ++++++ .../react/src/checkbox/root/CheckboxRoot.tsx | 5 +- .../react/src/slider/root/SliderRoot.test.tsx | 47 +++++++++++++++++++ .../react/src/slider/thumb/SliderThumb.tsx | 2 +- .../react/src/switch/root/SwitchRoot.test.tsx | 15 ++++++ packages/react/src/switch/root/SwitchRoot.tsx | 2 +- 6 files changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/react/src/checkbox/root/CheckboxRoot.test.tsx b/packages/react/src/checkbox/root/CheckboxRoot.test.tsx index 936c88b22..9ec782577 100644 --- a/packages/react/src/checkbox/root/CheckboxRoot.test.tsx +++ b/packages/react/src/checkbox/root/CheckboxRoot.test.tsx @@ -1202,6 +1202,21 @@ describe('', () => { expect(button).toHaveAttribute('aria-invalid', 'true'); }); + it('validates once when changed by the user', async () => { + const validate = vi.fn(); + + const { user } = await render( + + + , + ); + + await user.click(screen.getByRole('checkbox')); + + expect(validate).toHaveBeenCalledTimes(1); + expect(validate.mock.lastCall?.[0]).toBe(true); + }); + it('revalidates when a controlled value changes externally', async () => { const validateSpy = vi.fn((value: unknown) => ((value as boolean) ? 'error' : null)); diff --git a/packages/react/src/checkbox/root/CheckboxRoot.tsx b/packages/react/src/checkbox/root/CheckboxRoot.tsx index e1a566ee4..e19fa973c 100644 --- a/packages/react/src/checkbox/root/CheckboxRoot.tsx +++ b/packages/react/src/checkbox/root/CheckboxRoot.tsx @@ -259,10 +259,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot( ? { value: (groupContext ? checked && valueProp : valueProp) || '' } : EMPTY_OBJECT, getDescriptionProps, - (props) => - groupContext - ? validation.getValidationProps(disabled, props) - : validation.getInputValidationProps(disabled, props), + (props) => validation.getValidationProps(disabled, props), ); const computedChecked = isGroupedWithParent ? Boolean(groupChecked) : checked; diff --git a/packages/react/src/slider/root/SliderRoot.test.tsx b/packages/react/src/slider/root/SliderRoot.test.tsx index 3dde9671b..147815718 100644 --- a/packages/react/src/slider/root/SliderRoot.test.tsx +++ b/packages/react/src/slider/root/SliderRoot.test.tsx @@ -2459,6 +2459,53 @@ describe.skipIf(typeof Touch === 'undefined')('', () => { expect(input).toHaveAttribute('aria-invalid', 'true'); }); + it('validates once when changed by the user', async () => { + const validate = vi.fn(); + + const { user } = await render( + + + + + + + , + ); + + await user.keyboard('[Tab]'); + expect(screen.getByRole('slider')).toHaveFocus(); + + await user.keyboard(`[${ARROW_RIGHT}]`); + await flushMicrotasks(); + + expect(validate).toHaveBeenCalledTimes(1); + expect(validate.mock.lastCall?.[0]).toBe(1); + }); + + it('validates once with an array value for range sliders when changed by the user', async () => { + const validate = vi.fn(); + + const { user } = await render( + + + + + + + + , + ); + + await user.keyboard('[Tab]'); + expect(screen.getAllByRole('slider')[0]).toHaveFocus(); + + await user.keyboard(`[${ARROW_RIGHT}]`); + await flushMicrotasks(); + + expect(validate).toHaveBeenCalledTimes(1); + expect(validate.mock.lastCall?.[0]).toEqual([1, 5]); + }); + it('revalidates when the controlled value changes externally', async () => { const validateSpy = vi.fn((value: unknown) => (Number(value) === 5 ? 'error' : null)); diff --git a/packages/react/src/slider/thumb/SliderThumb.tsx b/packages/react/src/slider/thumb/SliderThumb.tsx index 68768f100..157822c5e 100644 --- a/packages/react/src/slider/thumb/SliderThumb.tsx +++ b/packages/react/src/slider/thumb/SliderThumb.tsx @@ -452,7 +452,7 @@ export const SliderThumb = React.forwardRef(function SliderThumb( type: 'range', value: thumbValue ?? '', }, - (props) => validation.getInputValidationProps(disabled, props), + (props) => validation.getValidationProps(disabled, props), ); const mergedInputRef = useMergedRefs(inputRef, validation.inputRef, inputRefProp); diff --git a/packages/react/src/switch/root/SwitchRoot.test.tsx b/packages/react/src/switch/root/SwitchRoot.test.tsx index 6d8e5ca36..c9ac83bd1 100644 --- a/packages/react/src/switch/root/SwitchRoot.test.tsx +++ b/packages/react/src/switch/root/SwitchRoot.test.tsx @@ -831,6 +831,21 @@ describe('', () => { expect(button).toHaveAttribute('aria-invalid', 'true'); }); + it('validates once when changed by the user', async () => { + const validate = vi.fn(); + + const { user } = await render( + + + , + ); + + await user.click(screen.getByRole('switch')); + + expect(validate).toHaveBeenCalledTimes(1); + expect(validate.mock.lastCall?.[0]).toBe(true); + }); + it('revalidates when a controlled value changes externally', async () => { const validateSpy = vi.fn((value: unknown) => ((value as boolean) ? 'error' : null)); diff --git a/packages/react/src/switch/root/SwitchRoot.tsx b/packages/react/src/switch/root/SwitchRoot.tsx index 29df41a62..b9b721e84 100644 --- a/packages/react/src/switch/root/SwitchRoot.tsx +++ b/packages/react/src/switch/root/SwitchRoot.tsx @@ -211,7 +211,7 @@ export const SwitchRoot = React.forwardRef(function SwitchRoot( switchRef.current?.focus(); }, }, - (props) => validation.getInputValidationProps(disabled, props), + (props) => validation.getValidationProps(disabled, props), // React <19 sets an empty value if `undefined` is passed explicitly // To avoid this, we only set the value if it's defined value !== undefined ? { value } : EMPTY_OBJECT,