Skip to content

fix(radio): allow native form onChange to fire for Radio selection - #10441

Open
starboyvarun wants to merge 1 commit into
adobe:mainfrom
starboyvarun:fix/radio-native-form-onchange
Open

fix(radio): allow native form onChange to fire for Radio selection#10441
starboyvarun wants to merge 1 commit into
adobe:mainfrom
starboyvarun:fix/radio-native-form-onchange

Conversation

@starboyvarun

Copy link
Copy Markdown

Closes #3799

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests for this change.
  • Filled out test instructions.
  • Updated documentation (no documented behavior needed updating; this restores documented-but-broken native form behavior).
  • Looked at the Accessibility Practices for this feature.

Summary

Radio's onChange handler called e.stopPropagation(), which prevented the native change event from ever bubbling to an ancestor <form onChange>. This matches a fix already identified and implemented by @devongovett on the change-events branch, which was never merged — this PR restores that fix for useRadio.

Separately, arrow-key navigation between radios in a RadioGroup only 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 native click on the newly-selected radio when navigating via keyboard (React's own change-detection for checkbox/radio inputs is keyed off the click event, not change/input), so keyboard selection is observed the same way a mouse click already is.

What changed

  • packages/react-aria/src/radio/useRadio.ts: removed e.stopPropagation() from the input's onChange handler.
  • packages/react-aria/src/radio/useRadioGroup.ts: after keyboard-driven selection in getNextElement, sets the native checked property (via the prototype's original setter, bypassing React's override) and dispatches a real, bubbling click event so native listeners observe the change.

📝 Test Instructions:

Two new tests in packages/react-aria-components/test/RadioGroup.test.js, run for both RadioGroup and RadioField:

  • should fire a native form onChange event when a radio is clicked
  • should fire a native form onChange event when selection changes via the keyboard

Both wrap the component in a real <form onChange> and assert it fires exactly once per interaction, alongside asserting the component's own onChange prop still fires exactly once (regression guard against double-firing).

Verified passing:

  • packages/react-aria-components/test/RadioGroup.test.js — 74/74
  • packages/@adobe/react-spectrum/test/radio/Radio.test.js (v3) — 34/34
  • packages/@react-spectrum/s2/test/RadioGroup.test.tsx (S2) — 27/27
  • yarn oxfmt --check, yarn oxlint, yarn check-types — all clean

🧢 Your Project:

Personal contribution.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern appears to be coming from some places like:
react/react#10135
https://gist.github.com/kentcdodds/5e54cdbadf1b08ed7003c33fc00bab4c

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this approach, what else did you consider?

@starboyvarun starboyvarun Aug 11, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native HTML form change handler is not triggered for input type radio

2 participants