From f570af54952735d44558f4af5e5904502be60f56 Mon Sep 17 00:00:00 2001 From: varun2735126 Date: Sun, 9 Aug 2026 21:03:33 +0530 Subject: [PATCH] fix(radio): allow native form onChange to fire for Radio selection Radio's onChange handler called e.stopPropagation(), preventing the native change event from ever reaching an ancestor
. Devon Govett already identified and fixed this exact issue on an abandoned branch (change-events) that was never merged; this restores that fix for useRadio. Additionally, arrow-key navigation between radios in a RadioGroup only updates React state directly and never fires any native DOM event, so even with the above fix, keyboard-driven selection was still invisible to a native . This replays a native click on the newly selected radio (React's own change-detection for checkbox/radio inputs is keyed off "click", not "change"/"input") so keyboard selection is observed the same way a mouse click already is. Fixes #3799 --- .../test/RadioGroup.test.js | 40 +++++++++++++++++++ packages/react-aria/src/radio/useRadio.ts | 3 +- .../react-aria/src/radio/useRadioGroup.ts | 14 +++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 596497a88a3..f0524a94319 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -397,6 +397,46 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(onChange).toHaveBeenLastCalledWith('c'); }); + it('should fire a native form onChange event when a radio is clicked', async () => { + let formOnChange = jest.fn(); + let onChange = jest.fn(); + let {getAllByRole} = render( + + + + ); + let radios = getAllByRole('radio'); + + await user.click(radios[1]); + expect(radios[1]).toBeChecked(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith('b'); + expect(formOnChange).toHaveBeenCalledTimes(1); + }); + + it('should fire a native form onChange event when selection changes via the keyboard', async () => { + let formOnChange = jest.fn(); + let onChange = jest.fn(); + let {getAllByRole} = render( +
+ + + ); + let radios = getAllByRole('radio'); + + await user.tab(); + expect(radios[0]).toHaveFocus(); + + // user-event implements its own radio-group arrow navigation, which bypasses our own code + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + + expect(radios[1]).toBeChecked(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith('b'); + expect(formOnChange).toHaveBeenCalledTimes(1); + }); + it('should support read only state', () => { let className = ({isReadOnly}) => (isReadOnly ? 'readonly' : ''); let {getByRole, getAllByRole} = renderGroup({isReadOnly: true, className}, {className}); diff --git a/packages/react-aria/src/radio/useRadio.ts b/packages/react-aria/src/radio/useRadio.ts index a00ba7e4aa3..277b91aa2b1 100644 --- a/packages/react-aria/src/radio/useRadio.ts +++ b/packages/react-aria/src/radio/useRadio.ts @@ -101,8 +101,7 @@ export function useRadio( let checked = state.selectedValue === value; - let onChange = e => { - e.stopPropagation(); + let onChange = () => { state.setSelectedValue(value); }; diff --git a/packages/react-aria/src/radio/useRadioGroup.ts b/packages/react-aria/src/radio/useRadioGroup.ts index 317d3b8b7c9..ef9ba2eec95 100644 --- a/packages/react-aria/src/radio/useRadioGroup.ts +++ b/packages/react-aria/src/radio/useRadioGroup.ts @@ -114,6 +114,20 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState // Call focus on nextElem so that keyboard navigation scrolls the radio into view nextElem.focus(); state.setSelectedValue(nextElem.value); + + // The state update above only changes the DOM `checked` property via React's controlled + // re-render — it does not dispatch any native event the way a real click does. React's own + // change detection for checkbox/radio inputs is keyed off the "click" event (a legacy + // browser-compat shim), not "change" or "input", so we replay a native click to make an + // ancestor
(or any other native DOM listener) observe keyboard-driven + // selection the same way it observes a mouse click. + let checkedSetter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(nextElem), + 'checked' + )!.set!; + checkedSetter.call(nextElem, true); + nextElem.dispatchEvent(new MouseEvent('click', {bubbles: true})); + return true; } return false;