Skip to content

Commit 999df3e

Browse files
jquensenhunzaker
authored andcommitted
Fix uncontrolled radios (#10156)
* Add fixture * Fix Uncontrolled radio groups * address feedback * fix tests; prettier * Update TestCase.js
1 parent 2dcdc3c commit 999df3e

File tree

9 files changed

+140
-32
lines changed

9 files changed

+140
-32
lines changed

fixtures/dom/public/react-loader.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ var query = parseQuery(window.location.search);
2828
var version = query.version || 'local';
2929

3030
if (version !== 'local') {
31-
REACT_PATH = 'https://unpkg.com/react@' + version + '/dist/react.min.js';
32-
DOM_PATH =
33-
'https://unpkg.com/react-dom@' + version + '/dist/react-dom.min.js';
31+
REACT_PATH = 'https://unpkg.com/react@' + version + '/dist/react.js';
32+
DOM_PATH = 'https://unpkg.com/react-dom@' + version + '/dist/react-dom.js';
3433
}
3534

3635
document.write('<script src="' + REACT_PATH + '"></script>');

fixtures/dom/src/components/TestCase.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const propTypes = {
99
children: PropTypes.node.isRequired,
1010
title: PropTypes.node.isRequired,
1111
resolvedIn: semverString,
12+
introducedIn: semverString,
1213
resolvedBy: PropTypes.string,
1314
};
1415

@@ -31,6 +32,7 @@ class TestCase extends React.Component {
3132
const {
3233
title,
3334
description,
35+
introducedIn,
3436
resolvedIn,
3537
resolvedBy,
3638
affectedBrowsers,
@@ -40,10 +42,10 @@ class TestCase extends React.Component {
4042
let {complete} = this.state;
4143

4244
const {version} = parse(window.location.search);
43-
const isTestRelevant =
45+
const isTestFixed =
4446
!version || !resolvedIn || semver.gte(version, resolvedIn);
4547

46-
complete = !isTestRelevant || complete;
48+
complete = !isTestFixed || complete;
4749

4850
return (
4951
<section className={cn('test-case', complete && 'test-case--complete')}>
@@ -60,6 +62,15 @@ class TestCase extends React.Component {
6062
</h2>
6163

6264
<dl className="test-case__details">
65+
{introducedIn && <dt>First broken in: </dt>}
66+
{introducedIn &&
67+
<dd>
68+
<a
69+
href={'https://github.com/facebook/react/tag/v' + introducedIn}>
70+
<code>{introducedIn}</code>
71+
</a>
72+
</dd>}
73+
6374
{resolvedIn && <dt>First supported in: </dt>}
6475
{resolvedIn &&
6576
<dd>
@@ -89,7 +100,7 @@ class TestCase extends React.Component {
89100
</p>
90101

91102
<div className="test-case__body">
92-
{!isTestRelevant &&
103+
{!isTestFixed &&
93104
<p className="test-case__invalid-version">
94105
<strong>Note:</strong>
95106
{' '}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react';
2+
3+
import Fixture from '../../Fixture';
4+
5+
class RadioGroupFixture extends React.Component {
6+
constructor(props, context) {
7+
super(props, context);
8+
9+
this.state = {
10+
changeCount: 0,
11+
};
12+
}
13+
14+
handleChange = () => {
15+
this.setState(({changeCount}) => {
16+
return {
17+
changeCount: changeCount + 1,
18+
};
19+
});
20+
};
21+
22+
handleReset = () => {
23+
this.setState({
24+
changeCount: 0,
25+
});
26+
};
27+
28+
render() {
29+
const {changeCount} = this.state;
30+
const color = changeCount === 2 ? 'green' : 'red';
31+
32+
return (
33+
<Fixture>
34+
<label>
35+
<input
36+
defaultChecked
37+
name="foo"
38+
type="radio"
39+
onChange={this.handleChange}
40+
/>
41+
Radio 1
42+
</label>
43+
<label>
44+
<input name="foo" type="radio" onChange={this.handleChange} />
45+
Radio 2
46+
</label>
47+
48+
{' '}
49+
<p style={{color}}>
50+
<code>onChange</code>{' calls: '}<strong>{changeCount}</strong>
51+
</p>
52+
<button onClick={this.handleReset}>Reset count</button>
53+
</Fixture>
54+
);
55+
}
56+
}
57+
58+
export default RadioGroupFixture;

fixtures/dom/src/components/fixtures/input-change-events/index.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import FixtureSet from '../../FixtureSet';
44
import TestCase from '../../TestCase';
55
import RangeKeyboardFixture from './RangeKeyboardFixture';
66
import RadioClickFixture from './RadioClickFixture';
7+
import RadioGroupFixture from './RadioGroupFixture';
78
import InputPlaceholderFixture from './InputPlaceholderFixture';
89

910
class InputChangeEvents extends React.Component {
@@ -47,6 +48,24 @@ class InputChangeEvents extends React.Component {
4748

4849
<RadioClickFixture />
4950
</TestCase>
51+
<TestCase
52+
title="Uncontrolled radio groups"
53+
description={`
54+
Radio inputs should fire change events when the value moved to
55+
another named input
56+
`}
57+
introducedIn="15.6.0">
58+
<TestCase.Steps>
59+
<li>Click on the "Radio 2"</li>
60+
<li>Click back to "Radio 1"</li>
61+
</TestCase.Steps>
62+
63+
<TestCase.ExpectedResult>
64+
The <code>onChange</code> call count should equal 2
65+
</TestCase.ExpectedResult>
66+
67+
<RadioGroupFixture />
68+
</TestCase>
5069

5170
<TestCase
5271
title="Inputs with placeholders"

fixtures/dom/yarn.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,6 +2199,18 @@ fbjs@^0.8.1, fbjs@^0.8.4:
21992199
setimmediate "^1.0.5"
22002200
ua-parser-js "^0.7.9"
22012201

2202+
fbjs@^0.8.9:
2203+
version "0.8.12"
2204+
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.12.tgz#10b5d92f76d45575fd63a217d4ea02bea2f8ed04"
2205+
dependencies:
2206+
core-js "^1.0.0"
2207+
isomorphic-fetch "^2.1.1"
2208+
loose-envify "^1.0.0"
2209+
object-assign "^4.1.0"
2210+
promise "^7.1.1"
2211+
setimmediate "^1.0.5"
2212+
ua-parser-js "^0.7.9"
2213+
22022214
figures@^1.3.5:
22032215
version "1.7.0"
22042216
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"

src/renderers/dom/fiber/ReactDOMFiberComponent.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,10 @@ var ReactDOMFiberComponent = {
754754
// happen after `updateDOMProperties`. Otherwise HTML5 input validations
755755
// raise warnings and prevent the new value from being assigned.
756756
ReactDOMFiberInput.updateWrapper(domElement, nextRawProps);
757+
758+
// We also check that we haven't missed a value update, such as a
759+
// Radio group shifting the checked value to another named radio input.
760+
inputValueTracking.updateValueIfChanged((domElement: any));
757761
break;
758762
case 'textarea':
759763
ReactDOMFiberTextarea.updateWrapper(domElement, nextRawProps);

src/renderers/dom/shared/__tests__/inputValueTracking-test.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,11 @@ describe('inputValueTracking', () => {
145145
it('should stop tracking', () => {
146146
inputValueTracking.track(mockComponent);
147147

148-
expect(mockComponent._wrapperState.hasOwnProperty('valueTracker')).toBe(
149-
true,
150-
);
148+
expect(mockComponent._wrapperState.valueTracker).not.toEqual(null);
151149

152150
inputValueTracking.stopTracking(mockComponent);
153151

154-
expect(mockComponent._wrapperState.hasOwnProperty('valueTracker')).toBe(
155-
false,
156-
);
152+
expect(mockComponent._wrapperState.valueTracker).toEqual(null);
157153

158154
expect(input.hasOwnProperty('value')).toBe(false);
159155
});

src/renderers/dom/shared/inputValueTracking.js

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
'use strict';
1414

15+
var {ELEMENT_NODE} = require('HTMLNodeType');
1516
import type {Fiber} from 'ReactFiber';
1617
import type {ReactInstance} from 'ReactInstanceType';
1718

@@ -23,6 +24,9 @@ type ValueTracker = {
2324
type WrapperState = {_wrapperState: {valueTracker: ?ValueTracker}};
2425
type ElementWithWrapperState = Element & WrapperState;
2526
type InstanceWithWrapperState = ReactInstance & WrapperState;
27+
type SubjectWithWrapperState =
28+
| InstanceWithWrapperState
29+
| ElementWithWrapperState;
2630

2731
var ReactDOMComponentTree = require('ReactDOMComponentTree');
2832

@@ -43,15 +47,11 @@ function getTracker(inst: any) {
4347
return inst._wrapperState.valueTracker;
4448
}
4549

46-
function attachTracker(inst: InstanceWithWrapperState, tracker: ?ValueTracker) {
47-
inst._wrapperState.valueTracker = tracker;
50+
function detachTracker(subject: SubjectWithWrapperState) {
51+
subject._wrapperState.valueTracker = null;
4852
}
4953

50-
function detachTracker(inst: InstanceWithWrapperState) {
51-
delete inst._wrapperState.valueTracker;
52-
}
53-
54-
function getValueFromNode(node) {
54+
function getValueFromNode(node: any) {
5555
var value;
5656
if (node) {
5757
value = isCheckable(node) ? '' + node.checked : node.value;
@@ -113,40 +113,46 @@ var inputValueTracking = {
113113
return getTracker(ReactDOMComponentTree.getInstanceFromNode(node));
114114
},
115115

116-
trackNode: function(node: ElementWithWrapperState) {
117-
if (node._wrapperState.valueTracker) {
116+
trackNode(node: ElementWithWrapperState) {
117+
if (getTracker(node)) {
118118
return;
119119
}
120120
node._wrapperState.valueTracker = trackValueOnNode(node, node);
121121
},
122122

123-
track: function(inst: InstanceWithWrapperState) {
123+
track(inst: InstanceWithWrapperState) {
124124
if (getTracker(inst)) {
125125
return;
126126
}
127127
var node = ReactDOMComponentTree.getNodeFromInstance(inst);
128-
attachTracker(inst, trackValueOnNode(node, inst));
128+
inst._wrapperState.valueTracker = trackValueOnNode(node, inst);
129129
},
130130

131-
updateValueIfChanged(inst: InstanceWithWrapperState | Fiber) {
132-
if (!inst) {
131+
updateValueIfChanged(subject: SubjectWithWrapperState | Fiber) {
132+
if (!subject) {
133133
return false;
134134
}
135-
var tracker = getTracker(inst);
135+
var tracker = getTracker(subject);
136136

137137
if (!tracker) {
138-
if (typeof (inst: any).tag === 'number') {
139-
inputValueTracking.trackNode((inst: any).stateNode);
138+
if (typeof (subject: any).tag === 'number') {
139+
inputValueTracking.trackNode((subject: any).stateNode);
140140
} else {
141-
inputValueTracking.track((inst: any));
141+
inputValueTracking.track((subject: any));
142142
}
143143
return true;
144144
}
145145

146146
var lastValue = tracker.getValue();
147-
var nextValue = getValueFromNode(
148-
ReactDOMComponentTree.getNodeFromInstance(inst),
149-
);
147+
148+
var node = subject;
149+
150+
// TODO: remove check when the Stack renderer is retired
151+
if ((subject: any).nodeType !== ELEMENT_NODE) {
152+
node = ReactDOMComponentTree.getNodeFromInstance(subject);
153+
}
154+
155+
var nextValue = getValueFromNode(node);
150156

151157
if (nextValue !== lastValue) {
152158
tracker.setValue(nextValue);

src/renderers/dom/stack/client/ReactDOMComponent.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,9 @@ ReactDOMComponent.Mixin = {
860860
// happen after `_updateDOMProperties`. Otherwise HTML5 input validations
861861
// raise warnings and prevent the new value from being assigned.
862862
ReactDOMInput.updateWrapper(this);
863+
// We also check that we haven't missed a value update, such as a
864+
// Radio group shifting the checked value to another named radio input.
865+
inputValueTracking.updateValueIfChanged(this);
863866
break;
864867
case 'textarea':
865868
ReactDOMTextarea.updateWrapper(this);

0 commit comments

Comments
 (0)