Skip to content

Commit da69b6a

Browse files
authored
ReactDOM.requestFormReset (#28809)
Based on: - #28808 - #28804 --- This adds a React DOM method called requestFormReset that schedules a form reset to occur when the current transition completes. Internally, it's the same method that's called automatically whenever a form action is submitted. It only affects uncontrolled form inputs. See #28804 for details. The reason for the public API is so UI libraries can implement their own action-based APIs and maintain the form-resetting behavior, something like this: ```js function onSubmit(event) { // Disable default form submission behavior event.preventDefault(); const form = event.target; startTransition(async () => { // Request the form to reset once the action // has completed requestFormReset(form); // Call the user-provided action prop await action(new FormData(form)); }) } ```
1 parent 374b5d2 commit da69b6a

File tree

2 files changed

+423
-72
lines changed

2 files changed

+423
-72
lines changed

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

+349
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)