fix(radio): allow native form onChange to fire for Radio selection - #10441
fix(radio): allow native form onChange to fire for Radio selection#10441starboyvarun wants to merge 1 commit into
Conversation
Radio's onChange handler called e.stopPropagation(), preventing the native change event from ever reaching an ancestor <form onChange>. 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 <form onChange>. 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 adobe#3799
| // browser-compat shim), not "change" or "input", so we replay a native click to make an | ||
| // ancestor <form onChange> (or any other native DOM listener) observe keyboard-driven | ||
| // selection the same way it observes a mouse click. | ||
| let checkedSetter = Object.getOwnPropertyDescriptor( |
There was a problem hiding this comment.
This pattern appears to be coming from some places like:
react/react#10135
https://gist.github.com/kentcdodds/5e54cdbadf1b08ed7003c33fc00bab4c
There was a problem hiding this comment.
I don't love this approach, what else did you consider?
There was a problem hiding this comment.
Yeah, you caught me ,
I lifted that from the setNativeValue pattern floating around in those RTL/gist threads. Fair call, it shouldn't be in library code. It leans on React's internal value tracker, and now that I look again the new MouseEvent() is wrong too, since there's a getOwnerWindow call literally ten lines above it for iframe support.
Things I tried before landing on that one:-
First was just dispatching a bubbling change event. Cleaner, but it doesn't actually work — React uses click for radio/checkbox change detection (shouldUseClickEvent), so a native listener would see it but an ancestor React <form onChange> still wouldn't fire. Half a fix, so I dropped it.
Then I went down the tracker route, which is what you're looking at. It works, but yeah, I don't love it either now.
What I should have done from the start is just call nextElem.click():
nextElem.focus();
- state.setSelectedValue(nextElem.value);
+ nextElem.click();The browser sets checkedness through its own activation behavior instead of the checked setter, so the tracker goes stale on its own and React fires onChange down the normal path. useRadio's onChange then calls setSelectedValue for us, so that line goes away too. Nothing poking at React internals.
Ran it locally — RAC RadioGroup 74/74, v3 Radio 57/57, S2 RadioGroup 4/4. I also went and checked read-only/disabled, since setSelectedValue no-ops in those cases and I'm deleting the call: read-only arrow keys, read-only click, and disabled arrow keys all still leave the selection alone and never fire onChange. React restores the controlled checked after the click, so it holds up.
One other thing I noticed while measuring all this — my PR description is wrong. I said the stopPropagation() removal was about native events bubbling to the form. It isn't. If you attach a real form.addEventListener('change', ...), that already works on main for mouse clicks; it was only ever broken for keyboard. What stopPropagation() actually killed was the synthetic event reaching an ancestor React onChange. I'll rewrite the description and add a test with an actual native listener, because right now both my tests use React's onChange on the form and don't cover that case at all.
Before I push anything though — is .click() the direction you'd want here, or is there an approach you'd rather I take? I'm also fine splitting this up and landing just the stopPropagation removal first, with the keyboard side as a separate PR if you think that's worth discussing on its own. Happy to go whichever way you prefer rather than guessing again.
Closes #3799
✅ Pull Request Checklist:
Summary
Radio'sonChangehandler callede.stopPropagation(), which prevented the nativechangeevent from ever bubbling to an ancestor<form onChange>. This matches a fix already identified and implemented by @devongovett on thechange-eventsbranch, which was never merged — this PR restores that fix foruseRadio.Separately, arrow-key navigation between radios in a
RadioGrouponly updates React state directly and never dispatches any native DOM event, so even with the above fix, keyboard-driven selection was still invisible to a native<form onChange>. This PR also replays a nativeclickon the newly-selected radio when navigating via keyboard (React's own change-detection for checkbox/radio inputs is keyed off theclickevent, notchange/input), so keyboard selection is observed the same way a mouse click already is.What changed
packages/react-aria/src/radio/useRadio.ts: removede.stopPropagation()from the input'sonChangehandler.packages/react-aria/src/radio/useRadioGroup.ts: after keyboard-driven selection ingetNextElement, sets the nativecheckedproperty (via the prototype's original setter, bypassing React's override) and dispatches a real, bubblingclickevent so native listeners observe the change.📝 Test Instructions:
Two new tests in
packages/react-aria-components/test/RadioGroup.test.js, run for bothRadioGroupandRadioField:should fire a native form onChange event when a radio is clickedshould fire a native form onChange event when selection changes via the keyboardBoth wrap the component in a real
<form onChange>and assert it fires exactly once per interaction, alongside asserting the component's ownonChangeprop still fires exactly once (regression guard against double-firing).Verified passing:
packages/react-aria-components/test/RadioGroup.test.js— 74/74packages/@adobe/react-spectrum/test/radio/Radio.test.js(v3) — 34/34packages/@react-spectrum/s2/test/RadioGroup.test.tsx(S2) — 27/27yarn oxfmt --check,yarn oxlint,yarn check-types— all clean🧢 Your Project:
Personal contribution.