Skip to content

Commit 1a80690

Browse files
committed
Fix checkbox and radio hydration
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.
1 parent 26daa54 commit 1a80690

File tree

2 files changed

+181
-6
lines changed

2 files changed

+181
-6
lines changed

packages/react-dom-bindings/src/client/ReactDOMInput.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -292,12 +292,10 @@ export function initInput(
292292
typeof checkedOrDefault !== 'symbol' &&
293293
!!checkedOrDefault;
294294

295-
// The checked property never gets assigned. It must be manually set.
296-
// We don't want to do this when hydrating so that existing user input isn't
297-
// modified
298-
// TODO: I'm pretty sure this is a bug because initialValueTracking won't be
299-
// correct for the hydration case then.
300-
if (!isHydrating) {
295+
if (isHydrating) {
296+
// Detach .checked from .defaultChecked but leave user input alone
297+
node.checked = node.checked;
298+
} else {
301299
node.checked = !!initialChecked;
302300
}
303301

packages/react-dom/src/__tests__/ReactDOMInput-test.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ function emptyFunction() {}
1717
describe('ReactDOMInput', () => {
1818
let React;
1919
let ReactDOM;
20+
let ReactDOMClient;
2021
let ReactDOMServer;
22+
let Scheduler;
23+
let act;
24+
let assertLog;
2125
let setUntrackedValue;
2226
let setUntrackedChecked;
2327
let container;
@@ -87,7 +91,11 @@ describe('ReactDOMInput', () => {
8791

8892
React = require('react');
8993
ReactDOM = require('react-dom');
94+
ReactDOMClient = require('react-dom/client');
9095
ReactDOMServer = require('react-dom/server');
96+
Scheduler = require('scheduler');
97+
act = require('internal-test-utils').act;
98+
assertLog = require('internal-test-utils').assertLog;
9199

92100
container = document.createElement('div');
93101
document.body.appendChild(container);
@@ -1235,6 +1243,175 @@ describe('ReactDOMInput', () => {
12351243
assertInputTrackingIsCurrent(container);
12361244
});
12371245

1246+
it('should hydrate controlled radio buttons', async () => {
1247+
function App() {
1248+
const [current, setCurrent] = React.useState('a');
1249+
return (
1250+
<>
1251+
<input
1252+
type="radio"
1253+
name="fruit"
1254+
checked={current === 'a'}
1255+
onChange={() => {
1256+
Scheduler.log('click a');
1257+
setCurrent('a');
1258+
}}
1259+
/>
1260+
<input
1261+
type="radio"
1262+
name="fruit"
1263+
checked={current === 'b'}
1264+
onChange={() => {
1265+
Scheduler.log('click b');
1266+
setCurrent('b');
1267+
}}
1268+
/>
1269+
<input
1270+
type="radio"
1271+
name="fruit"
1272+
checked={current === 'c'}
1273+
onChange={() => {
1274+
Scheduler.log('click c');
1275+
// Let's say the user can't pick C
1276+
}}
1277+
/>
1278+
</>
1279+
);
1280+
}
1281+
const html = ReactDOMServer.renderToString(<App />);
1282+
container.innerHTML = html;
1283+
const [a, b, c] = container.querySelectorAll('input');
1284+
expect(a.checked).toBe(true);
1285+
expect(b.checked).toBe(false);
1286+
expect(c.checked).toBe(false);
1287+
expect(isCheckedDirty(a)).toBe(false);
1288+
expect(isCheckedDirty(b)).toBe(false);
1289+
expect(isCheckedDirty(c)).toBe(false);
1290+
1291+
// Click on B before hydrating
1292+
b.checked = true;
1293+
expect(isCheckedDirty(a)).toBe(true);
1294+
expect(isCheckedDirty(b)).toBe(true);
1295+
expect(isCheckedDirty(c)).toBe(false);
1296+
1297+
await act(async () => {
1298+
ReactDOMClient.hydrateRoot(container, <App />);
1299+
});
1300+
1301+
// Currently, we don't fire onChange when hydrating
1302+
assertLog([]);
1303+
// Strangely, we leave `b` checked even though we rendered A with
1304+
// checked={true} and B with checked={false}. Arguably this is a bug.
1305+
expect(a.checked).toBe(false);
1306+
expect(b.checked).toBe(true);
1307+
expect(c.checked).toBe(false);
1308+
expect(isCheckedDirty(a)).toBe(true);
1309+
expect(isCheckedDirty(b)).toBe(true);
1310+
expect(isCheckedDirty(c)).toBe(true);
1311+
assertInputTrackingIsCurrent(container);
1312+
1313+
// If we click on C now though...
1314+
await act(async () => {
1315+
setUntrackedChecked.call(c, true);
1316+
dispatchEventOnNode(c, 'click');
1317+
});
1318+
1319+
// then since C's onClick doesn't set state, A becomes rechecked.
1320+
assertLog(['click c']);
1321+
expect(a.checked).toBe(true);
1322+
expect(b.checked).toBe(false);
1323+
expect(c.checked).toBe(false);
1324+
expect(isCheckedDirty(a)).toBe(true);
1325+
expect(isCheckedDirty(b)).toBe(true);
1326+
expect(isCheckedDirty(c)).toBe(true);
1327+
assertInputTrackingIsCurrent(container);
1328+
1329+
// And we can also change to B properly after hydration.
1330+
await act(async () => {
1331+
setUntrackedChecked.call(b, true);
1332+
dispatchEventOnNode(b, 'click');
1333+
});
1334+
assertLog(['click b']);
1335+
expect(a.checked).toBe(false);
1336+
expect(b.checked).toBe(true);
1337+
expect(c.checked).toBe(false);
1338+
expect(isCheckedDirty(a)).toBe(true);
1339+
expect(isCheckedDirty(b)).toBe(true);
1340+
expect(isCheckedDirty(c)).toBe(true);
1341+
assertInputTrackingIsCurrent(container);
1342+
});
1343+
1344+
it('should hydrate uncontrolled radio buttons', async () => {
1345+
function App() {
1346+
return (
1347+
<>
1348+
<input
1349+
type="radio"
1350+
name="fruit"
1351+
defaultChecked={true}
1352+
onChange={() => Scheduler.log('click a')}
1353+
/>
1354+
<input
1355+
type="radio"
1356+
name="fruit"
1357+
defaultChecked={false}
1358+
onChange={() => Scheduler.log('click b')}
1359+
/>
1360+
<input
1361+
type="radio"
1362+
name="fruit"
1363+
defaultChecked={false}
1364+
onChange={() => Scheduler.log('click c')}
1365+
/>
1366+
</>
1367+
);
1368+
}
1369+
const html = ReactDOMServer.renderToString(<App />);
1370+
container.innerHTML = html;
1371+
const [a, b, c] = container.querySelectorAll('input');
1372+
expect(a.checked).toBe(true);
1373+
expect(b.checked).toBe(false);
1374+
expect(c.checked).toBe(false);
1375+
expect(isCheckedDirty(a)).toBe(false);
1376+
expect(isCheckedDirty(b)).toBe(false);
1377+
expect(isCheckedDirty(c)).toBe(false);
1378+
1379+
// Click on B before hydrating
1380+
b.checked = true;
1381+
expect(isCheckedDirty(a)).toBe(true);
1382+
expect(isCheckedDirty(b)).toBe(true);
1383+
expect(isCheckedDirty(c)).toBe(false);
1384+
1385+
await act(async () => {
1386+
ReactDOMClient.hydrateRoot(container, <App />);
1387+
});
1388+
1389+
// Currently, we don't fire onChange when hydrating
1390+
assertLog([]);
1391+
expect(a.checked).toBe(false);
1392+
expect(b.checked).toBe(true);
1393+
expect(c.checked).toBe(false);
1394+
expect(isCheckedDirty(a)).toBe(true);
1395+
expect(isCheckedDirty(b)).toBe(true);
1396+
expect(isCheckedDirty(c)).toBe(true);
1397+
assertInputTrackingIsCurrent(container);
1398+
1399+
// Click back to A
1400+
await act(async () => {
1401+
setUntrackedChecked.call(a, true);
1402+
dispatchEventOnNode(a, 'click');
1403+
});
1404+
1405+
assertLog(['click a']);
1406+
expect(a.checked).toBe(true);
1407+
expect(b.checked).toBe(false);
1408+
expect(c.checked).toBe(false);
1409+
expect(isCheckedDirty(a)).toBe(true);
1410+
expect(isCheckedDirty(b)).toBe(true);
1411+
expect(isCheckedDirty(c)).toBe(true);
1412+
assertInputTrackingIsCurrent(container);
1413+
});
1414+
12381415
it('should check the correct radio when the selected name moves', () => {
12391416
class App extends React.Component {
12401417
state = {

0 commit comments

Comments
 (0)