@@ -17,7 +17,11 @@ function emptyFunction() {}
1717describe ( '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