Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: Radio button onChange not called in current React Canary #26876

Closed
robrichard opened this issue May 31, 2023 · 6 comments · Fixed by #27394 or #27443
Closed

Bug: Radio button onChange not called in current React Canary #26876

robrichard opened this issue May 31, 2023 · 6 comments · Fixed by #27394 or #27443

Comments

@robrichard
Copy link

React version: 18.3.0-canary-a1f97589f-20230526

Steps To Reproduce

  1. Create radio buttons that toggle disabled in onChange
  2. After selecting each radio button, onChange is no longer called

Link to code example:

The following CodeSandbox demonstrates the issue with the current react canary version. The issue is not present when react & react-dom versions are changed to stable 18.2.0

https://codesandbox.io/s/react-canary-radio-buttons-deiqb3?file=/src/App.js

The current behavior

<input type="radio" />'s onChange prop is not called on subsequent clicks of the input

The expected behavior

<input type="radio" />'s onChange prop should be called on subsequent clicks of the input

@robrichard robrichard added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label May 31, 2023
@eps1lon
Copy link
Collaborator

eps1lon commented May 31, 2023

As far as I an tell this also repros without toggling disabled: https://codesandbox.io/s/react-canary-radio-buttons-forked-ydjdlp?file=/src/App.js

So it's just about changing the controlled checked delayed.

@Luk-z
Copy link

Luk-z commented Jun 15, 2023

The problem was introduced with 18.3.0-next-1f248bdd7-20230419. The previous version 18.3.0-next-d962f35ca-20230418 works.

@robrichard
Copy link
Author

It looks like it was this change that introduced this bug: #26667

@kambleaa007
Copy link

what are ways to fix this issue?

@zhengjitf
Copy link

FYI, I find something by investigating, as below:

extractEvents:

const inst = getTargetInstFunc(domEventName, targetInst);
if (inst) {
createAndAccumulateChangeEvent(
dispatchQueue,
inst,
nativeEvent,
nativeEventTarget,
);
return;
}

onChange isn't called because inst here is falsy in that process. PS: In our case, getTargetInstFunc should be equal to getTargetInstForClickEvent.

Diving into the inner functions as the followings.

getTargetInstForClickEvent:

function getInstIfValueChanged(targetInst: Object) {
const targetNode = getNodeFromInstance(targetInst);
if (updateValueIfChanged(((targetNode: any): HTMLInputElement))) {
return targetInst;
}
}

updateValueIfChanged:

const lastValue = tracker.getValue();
const nextValue = getValueFromNode(node);
if (nextValue !== lastValue) {
tracker.setValue(nextValue);
return true;
}
return false;

tracker:

const tracker = {
getValue() {
return currentValue;
},
setValue(value: string) {
if (__DEV__) {
checkFormFieldValueStringCoercion(value);
}
currentValue = '' + value;

We can find the key is that the tracked value should be different from the value get from node (getValueFromNode(node)) in our case. We can believe the tracked value was not updated after the delayed state change, which cause the issue.

Analysis by reproduction steps (Please visit this demo, which can print the tracked values):

  1. Initially:
    • One: checked (getValueFromNode(node) is 'true'), and the tracked value is 'true'
    • Two: unchecked, and the tracked value is 'false'
  2. Click 'Two', and wait for delayed setValue: onChange is called
    • One: unchecked, and the tracked value is 'false'
    • Two: checked, and the tracked value is 'true'
  3. Click 'One', and wait for the delayed setValue: onChange is called
    • One: checked, and the tracked value is 'true'
    • Two: unchecked, and the tracked value is 'true'
  4. Click 'Two', won't call onChange again

PS: The tracked value can be updated at the end of the event handler (by restoreControlledInputState), so Step 2 will result in the correct tracked values.

So, I think one solution is to update the tracked value (at the end of the commit mutation phase) when the checked prop changes.

I'd like to submit a PR to fix it. Any corrections or suggestions will be appreciated, (for I'm not sure if I miss anything important).

@Luk-z
Copy link

Luk-z commented Jun 28, 2023

I tried another way to understand what caused the bug.

I just checked the diff between a working commit (d962f35) and non-working commit (1f248bd) and figured out that the bug happens after removing this lines of code in updateProperties() function of packages/react-dom-bindings/src/client/ReactDOMComponent.js.

just restoring

              const checkedValue = nextProps.defaultChecked;
              const inputElement: HTMLInputElement = (domElement: any);
              inputElement.checked =
                !!checkedValue &&
                typeof checkedValue !== 'function' &&
                checkedValue !== 'symbol';

or just adding domElement.checked = checked; the bug disappear.

Maybe the problem is explained in this comment

 * If `checked` or `value` are not supplied (or null/undefined), user actions
 * that affect the checked state or value will trigger updates to the element.
 *
 * If they are supplied (and not null/undefined), the rendered element will not
 * trigger updates to the element. Instead, the props must change in order for
 * the rendered element to be updated.

inputElement.checked is not updated and will result null/undefined and the radio is no more controlled?

You can check this branch. I've also made a change to packages/react-devtools-shell/src/e2e-apps/ListApp.js to start devtools server with a simple radio example.
To run the server:

  • run yarn in the root directory and in packages/react-devtools-inline directory.
  • from to the root directory run yarn build-for-devtools
  • from packages/react-devtools-shell folder execute yarn start (better to open another shell for this command)
  • go to http://localhost:8080/e2e.html
React.b.mov

Luk-z referenced this issue Jun 28, 2023
I accidentally made a behavior change in the refactor. It turns out that
when switching off `checked` to an uncontrolled component, we used to
revert to the concept of "initialChecked" which used to be stored on
state.

When there's a diff to this computed prop and the value of props.checked
is null, then we end up in a case where it sets `checked` to
`initialChecked`:


https://github.com/facebook/react/blob/5cbe6258bc436b1683080a6d978c27849f1d9a22/packages/react-dom-bindings/src/client/ReactDOMInput.js#L69

Since we never changed `initialChecked` and it's not relevant if
non-null `checked` changes value, the only way this "change" could
trigger was if we move from having `checked` to having null.

This wasn't really consistent with how `value` works, where we instead
leave the current value in place regardless. So this is a "bug fix" that
changes `checked` to be consistent with `value` and just leave the
current value in place. This case should already have a warning in it
regardless since it's going from controlled to uncontrolled.

Related to that, there was also another issue observed in
#26596 (comment) and
#26588

We need to atomically apply mutations on radio buttons. I fixed this by
setting the name to empty before doing mutations to value/checked/type
in updateInput, and then set the name to whatever it should be. Setting
the name is what ends up atomically applying the changes.

---------

Co-authored-by: Sophie Alpert <git@sophiebits.com>
sophiebits added a commit that referenced this issue Sep 21, 2023
Fixes whatever part of #26876 and vercel/next.js#49499 that #27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with latest stable release (18.2.0). It's awkward that we keep the user's state even for controlled inputs.

Previously the .defaultChecked assignment done in updateInput() was changing the actual checkedness because the dirty flag wasn't getting set, meaning that hydrating could change which radio button is checked, even in the absence of user interaction! Now we go back to always detaching again.
sophiebits added a commit that referenced this issue Sep 21, 2023
Fixes whatever part of #26876 and vercel/next.js#49499 that #27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with latest stable release (18.2.0). It's awkward that we keep the user's state even for controlled inputs.

Previously the .defaultChecked assignment done in updateInput() was changing the actual checkedness because the dirty flag wasn't getting set, meaning that hydrating could change which radio button is checked, even in the absence of user interaction! Now we go back to always detaching again.
sophiebits added a commit that referenced this issue Sep 21, 2023
Fixes #26876, I think. Review each commit separately (all assertions
pass in main already, except the last assertInputTrackingIsClean in
"should control radio buttons").

I'm actually a little confused on two things here:
* All the isCheckedDirty assertions are true. But I don't think we set
.checked unconditionally? So how does this happen?
* #26876 (comment)
claims that
d962f35...1f248bd contains
the faulty change, but it doesn't appear to change the restoration logic
that I've touched here. (One difference outside restoration is that
updateProperties did previously set `.checked` when `nextProp !==
lastProp` whereas the new logic in updateInput is to set it when
`node.checked !== !!checked`.)

But it seems to me like we need this call here anyway, and if it fixes
it then it fixes it? I think technically speaking we probably should do
all the updateInput() calls and then all the updateValueIfChanged()
calls—in particular I think if clicking A changed the checked radio
button from B to C then the code as I have it would be incorrect, but
that also seems unlikely so idk whether to care.

cc @zhengjitf @Luk-z who did some investigation on the original issue
sophiebits added a commit that referenced this issue Sep 21, 2023
Fixes whatever part of #26876 and vercel/next.js#49499 that #27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with latest stable release (18.2.0). It's awkward that we keep the user's state even for controlled inputs.

Previously the .defaultChecked assignment done in updateInput() was changing the actual checkedness because the dirty flag wasn't getting set, meaning that hydrating could change which radio button is checked, even in the absence of user interaction! Now we go back to always detaching again.
github-actions bot pushed a commit that referenced this issue Sep 21, 2023
Fixes #26876, I think. Review each commit separately (all assertions
pass in main already, except the last assertInputTrackingIsClean in
"should control radio buttons").

I'm actually a little confused on two things here:
* All the isCheckedDirty assertions are true. But I don't think we set
.checked unconditionally? So how does this happen?
* #26876 (comment)
claims that
d962f35...1f248bd contains
the faulty change, but it doesn't appear to change the restoration logic
that I've touched here. (One difference outside restoration is that
updateProperties did previously set `.checked` when `nextProp !==
lastProp` whereas the new logic in updateInput is to set it when
`node.checked !== !!checked`.)

But it seems to me like we need this call here anyway, and if it fixes
it then it fixes it? I think technically speaking we probably should do
all the updateInput() calls and then all the updateValueIfChanged()
calls—in particular I think if clicking A changed the checked radio
button from B to C then the code as I have it would be incorrect, but
that also seems unlikely so idk whether to care.

cc @zhengjitf @Luk-z who did some investigation on the original issue

DiffTrain build for [3c27178](3c27178)
sophiebits added a commit that referenced this issue Sep 30, 2023
Fixes #26876 for real?

In 18.2.0 (last stable), we set .checked unconditionally:

https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMInput.js#L129-L135

This is important because if we are updating two radios' checkedness from (false, true) to (true, false), we need to make sure that input2.checked is explicitly set to false, even though setting `input1.checked = true` already unchecks input2.

I think this fix is not complete because there is no guarantee that all the inputs rerender at the same time? Hence the TODO. But in practice they usually would and I _think_ this is comparable to what we had before.

Also treating function and symbol as false like we used to and like we do on initial mount.
sophiebits added a commit that referenced this issue Sep 30, 2023
Fixes #26876 for real?

In 18.2.0 (last stable), we set .checked unconditionally:

https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMInput.js#L129-L135

This is important because if we are updating two radios' checkedness from (false, true) to (true, false), we need to make sure that input2.checked is explicitly set to false, even though setting `input1.checked = true` already unchecks input2.

I think this fix is not complete because there is no guarantee that all the inputs rerender at the same time? Hence the TODO. But in practice they usually would and I _think_ this is comparable to what we had before.

Also treating function and symbol as false like we used to and like we do on initial mount.
sophiebits added a commit that referenced this issue Oct 2, 2023
Fixes #26876 for real?

In 18.2.0 (last stable), we set .checked unconditionally:


https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMInput.js#L129-L135

This is important because if we are updating two radios' checkedness
from (false, true) to (true, false), we need to make sure that
input2.checked is explicitly set to false, even though setting
`input1.checked = true` already unchecks input2.

I think this fix is not complete because there is no guarantee that all
the inputs rerender at the same time? Hence the TODO. But in practice
they usually would and I _think_ this is comparable to what we had
before.

Also treating function and symbol as false like we used to and like we
do on initial mount.
sophiebits added a commit that referenced this issue Oct 2, 2023
Fixes whatever part of #26876
and vercel/next.js#49499 that
#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.
github-actions bot pushed a commit that referenced this issue Oct 2, 2023
Fixes #26876 for real?

In 18.2.0 (last stable), we set .checked unconditionally:

https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMInput.js#L129-L135

This is important because if we are updating two radios' checkedness
from (false, true) to (true, false), we need to make sure that
input2.checked is explicitly set to false, even though setting
`input1.checked = true` already unchecks input2.

I think this fix is not complete because there is no guarantee that all
the inputs rerender at the same time? Hence the TODO. But in practice
they usually would and I _think_ this is comparable to what we had
before.

Also treating function and symbol as false like we used to and like we
do on initial mount.

DiffTrain build for [4f4c52a](4f4c52a)
github-actions bot pushed a commit that referenced this issue Oct 3, 2023
Fixes whatever part of #26876
and vercel/next.js#49499 that
#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.

DiffTrain build for [db69f95](db69f95)
alunyov pushed a commit to alunyov/react that referenced this issue Oct 11, 2023
Fixes facebook#26876 for real?

In 18.2.0 (last stable), we set .checked unconditionally:


https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMInput.js#L129-L135

This is important because if we are updating two radios' checkedness
from (false, true) to (true, false), we need to make sure that
input2.checked is explicitly set to false, even though setting
`input1.checked = true` already unchecks input2.

I think this fix is not complete because there is no guarantee that all
the inputs rerender at the same time? Hence the TODO. But in practice
they usually would and I _think_ this is comparable to what we had
before.

Also treating function and symbol as false like we used to and like we
do on initial mount.
alunyov pushed a commit to alunyov/react that referenced this issue Oct 11, 2023
Fixes whatever part of facebook#26876
and vercel/next.js#49499 that
facebook#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.
jerrydev0927 added a commit to jerrydev0927/react that referenced this issue Jan 5, 2024
Fixes #26876, I think. Review each commit separately (all assertions
pass in main already, except the last assertInputTrackingIsClean in
"should control radio buttons").

I'm actually a little confused on two things here:
* All the isCheckedDirty assertions are true. But I don't think we set
.checked unconditionally? So how does this happen?
* facebook/react#26876 (comment)
claims that
facebook/react@d962f35...1f248bd contains
the faulty change, but it doesn't appear to change the restoration logic
that I've touched here. (One difference outside restoration is that
updateProperties did previously set `.checked` when `nextProp !==
lastProp` whereas the new logic in updateInput is to set it when
`node.checked !== !!checked`.)

But it seems to me like we need this call here anyway, and if it fixes
it then it fixes it? I think technically speaking we probably should do
all the updateInput() calls and then all the updateValueIfChanged()
calls—in particular I think if clicking A changed the checked radio
button from B to C then the code as I have it would be incorrect, but
that also seems unlikely so idk whether to care.

cc @zhengjitf @Luk-z who did some investigation on the original issue

DiffTrain build for [3c27178a2f2c74f14d90613028e3929e1f06d830](facebook/react@3c27178)
jerrydev0927 added a commit to jerrydev0927/react that referenced this issue Jan 5, 2024
Fixes whatever part of facebook/react#26876
and vercel/next.js#49499 that
facebook/react#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.

DiffTrain build for [db69f95e4876ec3c24117f58d55cbb4f315b9fa7](facebook/react@db69f95)
jerrydev0927 added a commit to jerrydev0927/react that referenced this issue Jan 5, 2024
Fixes whatever part of facebook/react#26876 and vercel/next.js#49499 that facebook/react#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with latest stable release (18.2.0). It's awkward that we keep the user's state even for controlled inputs.

Previously the .defaultChecked assignment done in updateInput() was changing the actual checkedness because the dirty flag wasn't getting set, meaning that hydrating could change which radio button is checked, even in the absence of user interaction! Now we go back to always detaching again.
EdisonVan pushed a commit to EdisonVan/react that referenced this issue Apr 15, 2024
Fixes facebook#26876, I think. Review each commit separately (all assertions
pass in main already, except the last assertInputTrackingIsClean in
"should control radio buttons").

I'm actually a little confused on two things here:
* All the isCheckedDirty assertions are true. But I don't think we set
.checked unconditionally? So how does this happen?
* facebook#26876 (comment)
claims that
facebook/react@d962f35...1f248bd contains
the faulty change, but it doesn't appear to change the restoration logic
that I've touched here. (One difference outside restoration is that
updateProperties did previously set `.checked` when `nextProp !==
lastProp` whereas the new logic in updateInput is to set it when
`node.checked !== !!checked`.)

But it seems to me like we need this call here anyway, and if it fixes
it then it fixes it? I think technically speaking we probably should do
all the updateInput() calls and then all the updateValueIfChanged()
calls—in particular I think if clicking A changed the checked radio
button from B to C then the code as I have it would be incorrect, but
that also seems unlikely so idk whether to care.

cc @zhengjitf @Luk-z who did some investigation on the original issue
EdisonVan pushed a commit to EdisonVan/react that referenced this issue Apr 15, 2024
Fixes facebook#26876 for real?

In 18.2.0 (last stable), we set .checked unconditionally:


https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMInput.js#L129-L135

This is important because if we are updating two radios' checkedness
from (false, true) to (true, false), we need to make sure that
input2.checked is explicitly set to false, even though setting
`input1.checked = true` already unchecks input2.

I think this fix is not complete because there is no guarantee that all
the inputs rerender at the same time? Hence the TODO. But in practice
they usually would and I _think_ this is comparable to what we had
before.

Also treating function and symbol as false like we used to and like we
do on initial mount.
EdisonVan pushed a commit to EdisonVan/react that referenced this issue Apr 15, 2024
Fixes whatever part of facebook#26876
and vercel/next.js#49499 that
facebook#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.
bigfootjon pushed a commit that referenced this issue Apr 18, 2024
Fixes #26876 for real?

In 18.2.0 (last stable), we set .checked unconditionally:

https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMInput.js#L129-L135

This is important because if we are updating two radios' checkedness
from (false, true) to (true, false), we need to make sure that
input2.checked is explicitly set to false, even though setting
`input1.checked = true` already unchecks input2.

I think this fix is not complete because there is no guarantee that all
the inputs rerender at the same time? Hence the TODO. But in practice
they usually would and I _think_ this is comparable to what we had
before.

Also treating function and symbol as false like we used to and like we
do on initial mount.

DiffTrain build for commit 4f4c52a.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment