From fac07d7072341d3e7a36729cc9e4f76d9328d969 Mon Sep 17 00:00:00 2001 From: Tim Gesemann Date: Tue, 11 Aug 2026 18:09:15 +0200 Subject: [PATCH] fix: relax inputRef type on Checkbox, Switch, and Radio to accept callback refs CheckboxProps, SwitchProps, and RadioProps (and their Field variants) typed inputRef as RefObject, so a ref callback couldn't be passed even though mergeRefs already supports one. Widen inputRef to Ref on all six, widen useCheckboxAria's internal parameter to match, and memoize Switch's ref merge like Checkbox/Radio already do so a callback ref only tears down on unmount instead of every render. Closes #10319 --- packages/react-aria-components/src/Checkbox.tsx | 8 ++++---- .../react-aria-components/src/RadioGroup.tsx | 6 +++--- packages/react-aria-components/src/Switch.tsx | 16 +++++++++++----- .../react-aria-components/test/Checkbox.test.js | 9 +++++++++ .../test/RadioGroup.test.js | 17 +++++++++++++++++ .../react-aria-components/test/Switch.test.js | 9 +++++++++ 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 45404c4eba8..fd2298af0d5 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -39,7 +39,7 @@ import {HoverEvents} from '@react-types/shared'; import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; @@ -89,7 +89,7 @@ export interface CheckboxProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface CheckboxFieldProps @@ -109,7 +109,7 @@ export interface CheckboxFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface CheckboxButtonProps @@ -429,7 +429,7 @@ export const CheckboxField = /*#__PURE__*/ (forwardRef as forwardRefType)(functi function useCheckboxAria( props: CheckboxProps | CheckboxFieldProps, - userProvidedInputRef: RefObject | null + userProvidedInputRef: Ref | null ): [CheckboxAria, RefObject] { let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 4f9f59dfe8f..caf52c62906 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -40,7 +40,7 @@ import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import {RadioGroupState, useRadioGroupState} from 'react-stately/useRadioGroupState'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; @@ -89,7 +89,7 @@ export interface RadioProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface RadioFieldProps @@ -108,7 +108,7 @@ export interface RadioFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface RadioButtonProps diff --git a/packages/react-aria-components/src/Switch.tsx b/packages/react-aria-components/src/Switch.tsx index b81cd69c0b3..067458b5858 100644 --- a/packages/react-aria-components/src/Switch.tsx +++ b/packages/react-aria-components/src/Switch.tsx @@ -32,7 +32,7 @@ import {forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/share import {HoverEvents} from '@react-types/shared'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; import {ToggleState, useToggleState} from 'react-stately/useToggleState'; import {useFocusRing} from 'react-aria/useFocusRing'; @@ -65,7 +65,7 @@ export interface SwitchProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface SwitchFieldProps @@ -85,7 +85,7 @@ export interface SwitchFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface SwitchButtonProps @@ -225,7 +225,10 @@ export const Switch = /*#__PURE__*/ (forwardRef as forwardRefType)(function Swit let {inputRef: userProvidedInputRef = null, ...otherProps} = props; [props, ref] = useContextProps(otherProps, ref, SwitchContext); let inputRef = useObjectRef( - mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null) + useMemo( + () => mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null), + [userProvidedInputRef, props.inputRef] + ) ); let state = useToggleState(props); let aria = useSwitch( @@ -276,7 +279,10 @@ export const SwitchField = /*#__PURE__*/ (forwardRef as forwardRefType)(function let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useObjectRef( - mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null) + useMemo( + () => mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null), + [userProvidedInputRef, props.inputRef] + ) ); let state = useToggleState(props); let aria = useSwitch( diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js index f87ce806420..7a44370b3e5 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -423,6 +423,15 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('checkbox')); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render(Test); + expect(onRef).toHaveBeenCalledWith(getByRole('checkbox')); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 596497a88a3..2a95a682654 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -755,6 +755,23 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(inputRef.current).toBe(radio); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render( + + + + A + + + ); + let radio = getByRole('radio'); + expect(onRef).toHaveBeenCalledWith(radio); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); diff --git a/packages/react-aria-components/test/Switch.test.js b/packages/react-aria-components/test/Switch.test.js index 5433f99d85d..9f7d6d7f1ae 100644 --- a/packages/react-aria-components/test/Switch.test.js +++ b/packages/react-aria-components/test/Switch.test.js @@ -342,6 +342,15 @@ describe.each(['Switch', 'SwitchField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('switch')); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render(Test); + expect(onRef).toHaveBeenCalledWith(getByRole('switch')); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef();