@@ -43,6 +43,7 @@ describe('ReactDOMForm', () => {
4343 let textCache ;
4444 let useFormStatus ;
4545 let useActionState ;
46+ let requestFormReset ;
4647
4748 beforeEach ( ( ) => {
4849 jest . resetModules ( ) ;
@@ -58,6 +59,7 @@ describe('ReactDOMForm', () => {
5859 startTransition = React . startTransition ;
5960 use = React . use ;
6061 useFormStatus = ReactDOM . useFormStatus ;
62+ requestFormReset = ReactDOM . requestFormReset ;
6163 container = document . createElement ( 'div' ) ;
6264 document . body . appendChild ( container ) ;
6365
@@ -1414,4 +1416,351 @@ describe('ReactDOMForm', () => {
14141416 expect ( inputRef . current . value ) . toBe ( 'acdlite' ) ;
14151417 expect ( divRef . current . textContent ) . toEqual ( 'Current username: acdlite' ) ;
14161418 } ) ;
1419+
1420+ test ( 'requestFormReset schedules a form reset after transition completes' , async ( ) => {
1421+ // This is the same as the previous test, except the form is updated with
1422+ // a userspace action instead of a built-in form action.
1423+
1424+ const formRef = React . createRef ( ) ;
1425+ const inputRef = React . createRef ( ) ;
1426+ const divRef = React . createRef ( ) ;
1427+
1428+ function App ( { promiseForUsername} ) {
1429+ // Make this suspensey to simulate RSC streaming.
1430+ const username = use ( promiseForUsername ) ;
1431+
1432+ return (
1433+ < form ref = { formRef } >
1434+ < input
1435+ ref = { inputRef }
1436+ text = "text"
1437+ name = "username"
1438+ defaultValue = { username }
1439+ />
1440+ < div ref = { divRef } >
1441+ < Text text = { 'Current username: ' + username } />
1442+ </ div >
1443+ </ form >
1444+ ) ;
1445+ }
1446+
1447+ // Initial render
1448+ const root = ReactDOMClient . createRoot ( container ) ;
1449+ const promiseForInitialUsername = getText ( '(empty)' ) ;
1450+ await resolveText ( '(empty)' ) ;
1451+ await act ( ( ) =>
1452+ root . render ( < App promiseForUsername = { promiseForInitialUsername } /> ) ,
1453+ ) ;
1454+ assertLog ( [ 'Current username: (empty)' ] ) ;
1455+ expect ( divRef . current . textContent ) . toEqual ( 'Current username: (empty)' ) ;
1456+
1457+ // Dirty the uncontrolled input
1458+ inputRef . current . value = ' AcdLite ' ;
1459+
1460+ // This is a userspace action. It does not trigger a real form submission.
1461+ // The practical use case is implementing a custom action prop using
1462+ // onSubmit without losing the built-in form resetting behavior.
1463+ await act ( ( ) => {
1464+ startTransition ( async ( ) => {
1465+ const form = formRef . current ;
1466+ const formData = new FormData ( form ) ;
1467+ requestFormReset ( form ) ;
1468+
1469+ const rawUsername = formData . get ( 'username' ) ;
1470+ const normalizedUsername = rawUsername . trim ( ) . toLowerCase ( ) ;
1471+
1472+ Scheduler . log ( `Async action started` ) ;
1473+ await getText ( 'Wait' ) ;
1474+
1475+ // Update the app with new data. This is analagous to re-rendering
1476+ // from the root with a new RSC payload.
1477+ startTransition ( ( ) => {
1478+ root . render ( < App promiseForUsername = { getText ( normalizedUsername ) } /> ) ;
1479+ } ) ;
1480+ } ) ;
1481+ } ) ;
1482+ assertLog ( [ 'Async action started' ] ) ;
1483+ expect ( inputRef . current . value ) . toBe ( ' AcdLite ' ) ;
1484+
1485+ // Finish the async action. This will trigger a re-render from the root with
1486+ // new data from the "server", which suspends.
1487+ //
1488+ // The form should not reset yet because we need to update `defaultValue`
1489+ // first. So we wait for the render to complete.
1490+ await act ( ( ) => resolveText ( 'Wait' ) ) ;
1491+ assertLog ( [ ] ) ;
1492+ // The DOM input is still dirty.
1493+ expect ( inputRef . current . value ) . toBe ( ' AcdLite ' ) ;
1494+ // The React tree is suspended.
1495+ expect ( divRef . current . textContent ) . toEqual ( 'Current username: (empty)' ) ;
1496+
1497+ // Unsuspend and finish rendering. Now the form should be reset.
1498+ await act ( ( ) => resolveText ( 'acdlite' ) ) ;
1499+ assertLog ( [ 'Current username: acdlite' ] ) ;
1500+ // The form was reset to the new value from the server.
1501+ expect ( inputRef . current . value ) . toBe ( 'acdlite' ) ;
1502+ expect ( divRef . current . textContent ) . toEqual ( 'Current username: acdlite' ) ;
1503+ } ) ;
1504+
1505+ test (
1506+ 'requestFormReset works with inputs that are not descendants ' +
1507+ 'of the form element' ,
1508+ async ( ) => {
1509+ // This is the same as the previous test, except the input is not a child
1510+ // of the form; it's linked with <input form="myform" />
1511+
1512+ const formRef = React . createRef ( ) ;
1513+ const inputRef = React . createRef ( ) ;
1514+ const divRef = React . createRef ( ) ;
1515+
1516+ function App ( { promiseForUsername} ) {
1517+ // Make this suspensey to simulate RSC streaming.
1518+ const username = use ( promiseForUsername ) ;
1519+
1520+ return (
1521+ < >
1522+ < form id = "myform" ref = { formRef } />
1523+ < input
1524+ form = "myform"
1525+ ref = { inputRef }
1526+ text = "text"
1527+ name = "username"
1528+ defaultValue = { username }
1529+ />
1530+ < div ref = { divRef } >
1531+ < Text text = { 'Current username: ' + username } />
1532+ </ div >
1533+ </ >
1534+ ) ;
1535+ }
1536+
1537+ // Initial render
1538+ const root = ReactDOMClient . createRoot ( container ) ;
1539+ const promiseForInitialUsername = getText ( '(empty)' ) ;
1540+ await resolveText ( '(empty)' ) ;
1541+ await act ( ( ) =>
1542+ root . render ( < App promiseForUsername = { promiseForInitialUsername } /> ) ,
1543+ ) ;
1544+ assertLog ( [ 'Current username: (empty)' ] ) ;
1545+ expect ( divRef . current . textContent ) . toEqual ( 'Current username: (empty)' ) ;
1546+
1547+ // Dirty the uncontrolled input
1548+ inputRef . current . value = ' AcdLite ' ;
1549+
1550+ // This is a userspace action. It does not trigger a real form submission.
1551+ // The practical use case is implementing a custom action prop using
1552+ // onSubmit without losing the built-in form resetting behavior.
1553+ await act ( ( ) => {
1554+ startTransition ( async ( ) => {
1555+ const form = formRef . current ;
1556+ const formData = new FormData ( form ) ;
1557+ requestFormReset ( form ) ;
1558+
1559+ const rawUsername = formData . get ( 'username' ) ;
1560+ const normalizedUsername = rawUsername . trim ( ) . toLowerCase ( ) ;
1561+
1562+ Scheduler . log ( `Async action started` ) ;
1563+ await getText ( 'Wait' ) ;
1564+
1565+ // Update the app with new data. This is analagous to re-rendering
1566+ // from the root with a new RSC payload.
1567+ startTransition ( ( ) => {
1568+ root . render (
1569+ < App promiseForUsername = { getText ( normalizedUsername ) } /> ,
1570+ ) ;
1571+ } ) ;
1572+ } ) ;
1573+ } ) ;
1574+ assertLog ( [ 'Async action started' ] ) ;
1575+ expect ( inputRef . current . value ) . toBe ( ' AcdLite ' ) ;
1576+
1577+ // Finish the async action. This will trigger a re-render from the root with
1578+ // new data from the "server", which suspends.
1579+ //
1580+ // The form should not reset yet because we need to update `defaultValue`
1581+ // first. So we wait for the render to complete.
1582+ await act ( ( ) => resolveText ( 'Wait' ) ) ;
1583+ assertLog ( [ ] ) ;
1584+ // The DOM input is still dirty.
1585+ expect ( inputRef . current . value ) . toBe ( ' AcdLite ' ) ;
1586+ // The React tree is suspended.
1587+ expect ( divRef . current . textContent ) . toEqual ( 'Current username: (empty)' ) ;
1588+
1589+ // Unsuspend and finish rendering. Now the form should be reset.
1590+ await act ( ( ) => resolveText ( 'acdlite' ) ) ;
1591+ assertLog ( [ 'Current username: acdlite' ] ) ;
1592+ // The form was reset to the new value from the server.
1593+ expect ( inputRef . current . value ) . toBe ( 'acdlite' ) ;
1594+ expect ( divRef . current . textContent ) . toEqual ( 'Current username: acdlite' ) ;
1595+ } ,
1596+ ) ;
1597+
1598+ test ( 'reset multiple forms in the same transition' , async ( ) => {
1599+ const formRefA = React . createRef ( ) ;
1600+ const formRefB = React . createRef ( ) ;
1601+
1602+ function App ( { promiseForA, promiseForB} ) {
1603+ // Make these suspensey to simulate RSC streaming.
1604+ const a = use ( promiseForA ) ;
1605+ const b = use ( promiseForB ) ;
1606+ return (
1607+ < >
1608+ < form ref = { formRefA } >
1609+ < input type = "text" name = "inputName" defaultValue = { a } />
1610+ </ form >
1611+ < form ref = { formRefB } >
1612+ < input type = "text" name = "inputName" defaultValue = { b } />
1613+ </ form >
1614+ </ >
1615+ ) ;
1616+ }
1617+
1618+ const root = ReactDOMClient . createRoot ( container ) ;
1619+ const initialPromiseForA = getText ( 'A1' ) ;
1620+ const initialPromiseForB = getText ( 'B1' ) ;
1621+ await resolveText ( 'A1' ) ;
1622+ await resolveText ( 'B1' ) ;
1623+ await act ( ( ) =>
1624+ root . render (
1625+ < App
1626+ promiseForA = { initialPromiseForA }
1627+ promiseForB = { initialPromiseForB }
1628+ /> ,
1629+ ) ,
1630+ ) ;
1631+
1632+ // Dirty the uncontrolled inputs
1633+ formRefA . current . elements . inputName . value = ' A2 ' ;
1634+ formRefB . current . elements . inputName . value = ' B2 ' ;
1635+
1636+ // Trigger an async action that updates and reset both forms.
1637+ await act ( ( ) => {
1638+ startTransition ( async ( ) => {
1639+ const currentA = formRefA . current . elements . inputName . value ;
1640+ const currentB = formRefB . current . elements . inputName . value ;
1641+
1642+ requestFormReset ( formRefA . current ) ;
1643+ requestFormReset ( formRefB . current ) ;
1644+
1645+ Scheduler . log ( 'Async action started' ) ;
1646+ await getText ( 'Wait' ) ;
1647+
1648+ // Pretend the server did something with the data.
1649+ const normalizedA = currentA . trim ( ) ;
1650+ const normalizedB = currentB . trim ( ) ;
1651+
1652+ // Update the app with new data. This is analagous to re-rendering
1653+ // from the root with a new RSC payload.
1654+ startTransition ( ( ) => {
1655+ root . render (
1656+ < App
1657+ promiseForA = { getText ( normalizedA ) }
1658+ promiseForB = { getText ( normalizedB ) }
1659+ /> ,
1660+ ) ;
1661+ } ) ;
1662+ } ) ;
1663+ } ) ;
1664+ assertLog ( [ 'Async action started' ] ) ;
1665+
1666+ // Finish the async action. This will trigger a re-render from the root with
1667+ // new data from the "server", which suspends.
1668+ //
1669+ // The forms should not reset yet because we need to update `defaultValue`
1670+ // first. So we wait for the render to complete.
1671+ await act ( ( ) => resolveText ( 'Wait' ) ) ;
1672+
1673+ // The DOM inputs are still dirty.
1674+ expect ( formRefA . current . elements . inputName . value ) . toBe ( ' A2 ' ) ;
1675+ expect ( formRefB . current . elements . inputName . value ) . toBe ( ' B2 ' ) ;
1676+
1677+ // Unsuspend and finish rendering. Now the forms should be reset.
1678+ await act ( ( ) => {
1679+ resolveText ( 'A2' ) ;
1680+ resolveText ( 'B2' ) ;
1681+ } ) ;
1682+ // The forms were reset to the new value from the server.
1683+ expect ( formRefA . current . elements . inputName . value ) . toBe ( 'A2' ) ;
1684+ expect ( formRefB . current . elements . inputName . value ) . toBe ( 'B2' ) ;
1685+ } ) ;
1686+
1687+ test ( 'requestFormReset throws if the form is not managed by React' , async ( ) => {
1688+ container . innerHTML = `
1689+ <form id="myform">
1690+ <input id="input" type="text" name="greeting" />
1691+ </form>
1692+ ` ;
1693+
1694+ const form = document . getElementById ( 'myform' ) ;
1695+ const input = document . getElementById ( 'input' ) ;
1696+
1697+ input . value = 'Hi!!!!!!!!!!!!!' ;
1698+
1699+ expect ( ( ) => requestFormReset ( form ) ) . toThrow ( 'Invalid form element.' ) ;
1700+ // The form was not reset.
1701+ expect ( input . value ) . toBe ( 'Hi!!!!!!!!!!!!!' ) ;
1702+
1703+ // Just confirming a regular form reset works fine.
1704+ form . reset ( ) ;
1705+ expect ( input . value ) . toBe ( '' ) ;
1706+ } ) ;
1707+
1708+ test ( 'requestFormReset throws on a non-form DOM element' , async ( ) => {
1709+ const root = ReactDOMClient . createRoot ( container ) ;
1710+ const ref = React . createRef ( ) ;
1711+ await act ( ( ) => root . render ( < div ref = { ref } > Hi</ div > ) ) ;
1712+ const div = ref . current ;
1713+ expect ( div . textContent ) . toBe ( 'Hi' ) ;
1714+
1715+ expect ( ( ) => requestFormReset ( div ) ) . toThrow ( 'Invalid form element.' ) ;
1716+ } ) ;
1717+
1718+ test ( 'warns if requestFormReset is called outside of a transition' , async ( ) => {
1719+ const formRef = React . createRef ( ) ;
1720+ const inputRef = React . createRef ( ) ;
1721+
1722+ function App ( ) {
1723+ return (
1724+ < form ref = { formRef } >
1725+ < input ref = { inputRef } type = "text" defaultValue = "Initial" />
1726+ </ form >
1727+ ) ;
1728+ }
1729+
1730+ const root = ReactDOMClient . createRoot ( container ) ;
1731+ await act ( ( ) => root . render ( < App /> ) ) ;
1732+
1733+ // Dirty the uncontrolled input
1734+ inputRef . current . value = ' Updated ' ;
1735+
1736+ // Trigger an async action that updates and reset both forms.
1737+ await act ( ( ) => {
1738+ startTransition ( async ( ) => {
1739+ Scheduler . log ( 'Action started' ) ;
1740+ await getText ( 'Wait 1' ) ;
1741+ Scheduler . log ( 'Request form reset' ) ;
1742+
1743+ // This happens after an `await`, and is not wrapped in startTransition,
1744+ // so it will be scheduled synchronously instead of with the transition.
1745+ // This is almost certainly a mistake, so we log a warning in dev.
1746+ requestFormReset ( formRef . current ) ;
1747+
1748+ await getText ( 'Wait 2' ) ;
1749+ Scheduler . log ( 'Action finished' ) ;
1750+ } ) ;
1751+ } ) ;
1752+ assertLog ( [ 'Action started' ] ) ;
1753+ expect ( inputRef . current . value ) . toBe ( ' Updated ' ) ;
1754+
1755+ // This triggers a synchronous requestFormReset, and a warning
1756+ await expect ( async ( ) => {
1757+ await act ( ( ) => resolveText ( 'Wait 1' ) ) ;
1758+ } ) . toErrorDev ( [ 'requestFormReset was called outside a transition' ] , {
1759+ withoutStack : true ,
1760+ } ) ;
1761+ assertLog ( [ 'Request form reset' ] ) ;
1762+
1763+ // The form was reset even though the action didn't finish.
1764+ expect ( inputRef . current . value ) . toBe ( 'Initial' ) ;
1765+ } ) ;
14171766} ) ;
0 commit comments