@samitouri / QOS-React / commits / da69b6af96

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 https://github.com/facebook/react/pull/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)); }) } ```

Andrew Clark committed Apr 10, 2024 at 16:56 UTC da69b6af9697b8042834644b14d0e715d4ace18a
2 files changed +423 -72
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+349
@@ -43,6 +43,7 @@ describe('ReactDOMForm', () => {
43 let textCache;
44 let useFormStatus;
45 let useActionState;
46 + let requestFormReset;
47
48 beforeEach(() => {
49 jest.resetModules();
@@ -58,6 +59,7 @@ describe('ReactDOMForm', () => {
59 startTransition = React.startTransition;
60 use = React.use;
61 useFormStatus = ReactDOM.useFormStatus;
62 + requestFormReset = ReactDOM.requestFormReset;
63 container = document.createElement('div');
64 document.body.appendChild(container);
65
@@ -1414,4 +1416,351 @@ describe('ReactDOMForm', () => {
1416 expect(inputRef.current.value).toBe('acdlite');
1417 expect(divRef.current.textContent).toEqual('Current username: acdlite');
1418 });
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 + });
1766 });
packages/react-reconciler/src/ReactFiberHooks.js
+74 -72
@@ -2930,74 +2930,12 @@ export function startHostTransition<F>(
2930 );
2931 }
2932
2933 - let queue: UpdateQueue<
2933 + const stateHook = ensureFormComponentIsStateful(formFiber);
2934 +
2935 + const queue: UpdateQueue<
2936 Thenable<TransitionStatus> | TransitionStatus,
2937 BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
2936 - >;
2937 - if (formFiber.memoizedState === null) {
2938 - // Upgrade this host component fiber to be stateful. We're going to pretend
2939 - // it was stateful all along so we can reuse most of the implementation
2940 - // for function components and useTransition.
2941 - //
2942 - // Create the state hook used by TransitionAwareHostComponent. This is
2943 - // essentially an inlined version of mountState.
2944 - const newQueue: UpdateQueue<
2945 - Thenable<TransitionStatus> | TransitionStatus,
2946 - BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
2947 - > = {
2948 - pending: null,
2949 - lanes: NoLanes,
2950 - // We're going to cheat and intentionally not create a bound dispatch
2951 - // method, because we can call it directly in startTransition.
2952 - dispatch: (null: any),
2953 - lastRenderedReducer: basicStateReducer,
2954 - lastRenderedState: NoPendingHostTransition,
2955 - };
2956 - queue = newQueue;
2957 -
2958 - const stateHook: Hook = {
2959 - memoizedState: NoPendingHostTransition,
2960 - baseState: NoPendingHostTransition,
2961 - baseQueue: null,
2962 - queue: newQueue,
2963 - next: null,
2964 - };
2965 -
2966 - // We use another state hook to track whether the form needs to be reset.
2967 - // The state is an empty object. To trigger a reset, we update the state
2968 - // to a new object. Then during rendering, we detect that the state has
2969 - // changed and schedule a commit effect.
2970 - const initialResetState = {};
2971 - const newResetStateQueue: UpdateQueue<Object, Object> = {
2972 - pending: null,
2973 - lanes: NoLanes,
2974 - // We're going to cheat and intentionally not create a bound dispatch
2975 - // method, because we can call it directly in startTransition.
2976 - dispatch: (null: any),
2977 - lastRenderedReducer: basicStateReducer,
2978 - lastRenderedState: initialResetState,
2979 - };
2980 - const resetStateHook: Hook = {
2981 - memoizedState: initialResetState,
2982 - baseState: initialResetState,
2983 - baseQueue: null,
2984 - queue: newResetStateQueue,
2985 - next: null,
2986 - };
2987 - stateHook.next = resetStateHook;
2988 -
2989 - // Add the hook list to both fiber alternates. The idea is that the fiber
2990 - // had this hook all along.
2991 - formFiber.memoizedState = stateHook;
2992 - const alternate = formFiber.alternate;
2993 - if (alternate !== null) {
2994 - alternate.memoizedState = stateHook;
2995 - }
2996 - } else {
2997 - // This fiber was already upgraded to be stateful.
2998 - const stateHook: Hook = formFiber.memoizedState;
2999 - queue = stateHook.queue;
3000 - }
2938 + > = stateHook.queue;
2939
2940 startTransition(
2941 formFiber,
@@ -3008,18 +2946,81 @@ export function startHostTransition<F>(
2946 // once more of this function is implemented.
2947 () => {
2948 // Automatically reset the form when the action completes.
3011 - requestFormResetImpl(formFiber);
2949 + requestFormReset(formFiber);
2950 return callback(formData);
2951 },
2952 );
2953 }
2954
3017 -export function requestFormReset(formFiber: Fiber) {
3018 - // TODO: Not yet implemented. Need to upgrade the fiber to be stateful
3019 - // before scheduling the form reset.
2955 +function ensureFormComponentIsStateful(formFiber: Fiber) {
2956 + const existingStateHook: Hook | null = formFiber.memoizedState;
2957 + if (existingStateHook !== null) {
2958 + // This fiber was already upgraded to be stateful.
2959 + return existingStateHook;
2960 + }
2961 +
2962 + // Upgrade this host component fiber to be stateful. We're going to pretend
2963 + // it was stateful all along so we can reuse most of the implementation
2964 + // for function components and useTransition.
2965 + //
2966 + // Create the state hook used by TransitionAwareHostComponent. This is
2967 + // essentially an inlined version of mountState.
2968 + const newQueue: UpdateQueue<
2969 + Thenable<TransitionStatus> | TransitionStatus,
2970 + BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
2971 + > = {
2972 + pending: null,
2973 + lanes: NoLanes,
2974 + // We're going to cheat and intentionally not create a bound dispatch
2975 + // method, because we can call it directly in startTransition.
2976 + dispatch: (null: any),
2977 + lastRenderedReducer: basicStateReducer,
2978 + lastRenderedState: NoPendingHostTransition,
2979 + };
2980 +
2981 + const stateHook: Hook = {
2982 + memoizedState: NoPendingHostTransition,
2983 + baseState: NoPendingHostTransition,
2984 + baseQueue: null,
2985 + queue: newQueue,
2986 + next: null,
2987 + };
2988 +
2989 + // We use another state hook to track whether the form needs to be reset.
2990 + // The state is an empty object. To trigger a reset, we update the state
2991 + // to a new object. Then during rendering, we detect that the state has
2992 + // changed and schedule a commit effect.
2993 + const initialResetState = {};
2994 + const newResetStateQueue: UpdateQueue<Object, Object> = {
2995 + pending: null,
2996 + lanes: NoLanes,
2997 + // We're going to cheat and intentionally not create a bound dispatch
2998 + // method, because we can call it directly in startTransition.
2999 + dispatch: (null: any),
3000 + lastRenderedReducer: basicStateReducer,
3001 + lastRenderedState: initialResetState,
3002 + };
3003 + const resetStateHook: Hook = {
3004 + memoizedState: initialResetState,
3005 + baseState: initialResetState,
3006 + baseQueue: null,
3007 + queue: newResetStateQueue,
3008 + next: null,
3009 + };
3010 + stateHook.next = resetStateHook;
3011 +
3012 + // Add the hook list to both fiber alternates. The idea is that the fiber
3013 + // had this hook all along.
3014 + formFiber.memoizedState = stateHook;
3015 + const alternate = formFiber.alternate;
3016 + if (alternate !== null) {
3017 + alternate.memoizedState = stateHook;
3018 + }
3019 +
3020 + return stateHook;
3021 }
3022
3022 -function requestFormResetImpl(formFiber: Fiber) {
3023 +export function requestFormReset(formFiber: Fiber) {
3024 const transition = requestCurrentTransition();
3025
3026 if (__DEV__) {
@@ -3040,8 +3041,9 @@ function requestFormResetImpl(formFiber: Fiber) {
3041 }
3042 }
3043
3044 + const stateHook = ensureFormComponentIsStateful(formFiber);
3045 const newResetState = {};
3044 - const resetStateHook: Hook = (formFiber.memoizedState.next: any);
3046 + const resetStateHook: Hook = (stateHook.next: any);
3047 const resetStateQueue = resetStateHook.queue;
3048 dispatchSetState(formFiber, resetStateQueue, newResetState);
3049 }