@@ -17,7 +17,11 @@ function emptyFunction() {}
17
17
describe ( 'ReactDOMInput' , ( ) => {
18
18
let React ;
19
19
let ReactDOM ;
20
+ let ReactDOMClient ;
20
21
let ReactDOMServer ;
22
+ let Scheduler ;
23
+ let act ;
24
+ let assertLog ;
21
25
let setUntrackedValue ;
22
26
let setUntrackedChecked ;
23
27
let container ;
@@ -87,7 +91,11 @@ describe('ReactDOMInput', () => {
87
91
88
92
React = require ( 'react' ) ;
89
93
ReactDOM = require ( 'react-dom' ) ;
94
+ ReactDOMClient = require ( 'react-dom/client' ) ;
90
95
ReactDOMServer = require ( 'react-dom/server' ) ;
96
+ Scheduler = require ( 'scheduler' ) ;
97
+ act = require ( 'internal-test-utils' ) . act ;
98
+ assertLog = require ( 'internal-test-utils' ) . assertLog ;
91
99
92
100
container = document . createElement ( 'div' ) ;
93
101
document . body . appendChild ( container ) ;
@@ -1235,6 +1243,175 @@ describe('ReactDOMInput', () => {
1235
1243
assertInputTrackingIsCurrent ( container ) ;
1236
1244
} ) ;
1237
1245
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
+
1238
1415
it ( 'should check the correct radio when the selected name moves' , ( ) => {
1239
1416
class App extends React . Component {
1240
1417
state = {
0 commit comments