@samitouri / QOS-React / commits / c3345638cb

Support useFormStatus in progressively-enhanced forms (#29019)

Before this change, `useFormStatus` is only activated if a form is submitted by an action function (either `<form action={actionFn}>` or `<button formAction={actionFn}>`). After this change, `useFormStatus` will also be activated if you call `startTransition(actionFn)` inside a submit event handler that is `preventDefault`-ed. This is the last missing piece for implementing a custom `action` prop that is progressively enhanced using `onSubmit` while maintaining the same behavior as built-in form actions. Here's the basic recipe for implementing a progressively-enhanced form action. This would typically be implemented in your UI component library, not regular application code: ```js import {requestFormReset} from 'react-dom'; // To implement progressive enhancement, pass both a form action *and* a // submit event handler. The action is used for submissions that happen // before hydration, and the submit handler is used for submissions that // happen after. <form action={action} onSubmit={(event) => { // After hydration, we upgrade the form with additional client- // only behavior. event.preventDefault(); // Manually dispatch the action. startTransition(async () => { // (Optional) Reset any uncontrolled inputs once the action is // complete, like built-in form actions do. requestFormReset(event.target); // ...Do extra action-y stuff in here, like setting a custom // optimistic state... // Call the user-provided action const formData = new FormData(event.target); await action(formData); }); }} /> ```

Andrew Clark committed May 9, 2024 at 13:16 UTC c3345638cbb4a53df8151fd9ef106ea018fb5033
5 files changed +440 -54
packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js
+109 -44
@@ -14,11 +14,61 @@ import type {EventSystemFlags} from '../EventSystemFlags';
14 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
15 import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
16
17 +import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
18 import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
19 import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler';
20 +import {didCurrentEventScheduleTransition} from 'react-reconciler/src/ReactFiberRootScheduler';
21 +import sanitizeURL from 'react-dom-bindings/src/shared/sanitizeURL';
22 +import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
23
24 import {SyntheticEvent} from '../SyntheticEvent';
25
26 +function coerceFormActionProp(
27 + actionProp: mixed,
28 +): string | (FormData => void | Promise<void>) | null {
29 + // This should match the logic in ReactDOMComponent
30 + if (
31 + actionProp == null ||
32 + typeof actionProp === 'symbol' ||
33 + typeof actionProp === 'boolean'
34 + ) {
35 + return null;
36 + } else if (typeof actionProp === 'function') {
37 + return (actionProp: any);
38 + } else {
39 + if (__DEV__) {
40 + checkAttributeStringCoercion(actionProp, 'action');
41 + }
42 + return (sanitizeURL(
43 + enableTrustedTypesIntegration ? actionProp : '' + (actionProp: any),
44 + ): any);
45 + }
46 +}
47 +
48 +function createFormDataWithSubmitter(
49 + form: HTMLFormElement,
50 + submitter: HTMLInputElement | HTMLButtonElement,
51 +) {
52 + // The submitter's value should be included in the FormData.
53 + // It should be in the document order in the form.
54 + // Since the FormData constructor invokes the formdata event it also
55 + // needs to be available before that happens so after construction it's too
56 + // late. We use a temporary fake node for the duration of this event.
57 + // TODO: FormData takes a second argument that it's the submitter but this
58 + // is fairly new so not all browsers support it yet. Switch to that technique
59 + // when available.
60 + const temp = submitter.ownerDocument.createElement('input');
61 + temp.name = submitter.name;
62 + temp.value = submitter.value;
63 + if (form.id) {
64 + temp.setAttribute('form', form.id);
65 + }
66 + (submitter.parentNode: any).insertBefore(temp, submitter);
67 + const formData = new FormData(form);
68 + (temp.parentNode: any).removeChild(temp);
69 + return formData;
70 +}
71 +
72 /**
73 * This plugin invokes action functions on forms, inputs and buttons if
74 * the form doesn't prevent default.
@@ -42,16 +92,19 @@ function extractEvents(
92 }
93 const formInst = maybeTargetInst;
94 const form: HTMLFormElement = (nativeEventTarget: any);
45 - let action = (getFiberCurrentPropsFromNode(form): any).action;
46 - let submitter: null | HTMLInputElement | HTMLButtonElement =
95 + let action = coerceFormActionProp(
96 + (getFiberCurrentPropsFromNode(form): any).action,
97 + );
98 + let submitter: null | void | HTMLInputElement | HTMLButtonElement =
99 (nativeEvent: any).submitter;
100 let submitterAction;
101 if (submitter) {
102 const submitterProps = getFiberCurrentPropsFromNode(submitter);
103 submitterAction = submitterProps
52 - ? (submitterProps: any).formAction
53 - : submitter.getAttribute('formAction');
54 - if (submitterAction != null) {
104 + ? coerceFormActionProp((submitterProps: any).formAction)
105 + : // The built-in Flow type is ?string, wider than the spec
106 + ((submitter.getAttribute('formAction'): any): string | null);
107 + if (submitterAction !== null) {
108 // The submitter overrides the form action.
109 action = submitterAction;
110 // If the action is a function, we don't want to pass its name
@@ -60,10 +113,6 @@ function extractEvents(
113 }
114 }
115
63 - if (typeof action !== 'function') {
64 - return;
65 - }
66 -
116 const event = new SyntheticEvent(
117 'action',
118 'action',
@@ -74,44 +123,60 @@ function extractEvents(
123
124 function submitForm() {
125 if (nativeEvent.defaultPrevented) {
77 - // We let earlier events to prevent the action from submitting.
78 - return;
79 - }
80 - // Prevent native navigation.
81 - event.preventDefault();
82 - let formData;
83 - if (submitter) {
84 - // The submitter's value should be included in the FormData.
85 - // It should be in the document order in the form.
86 - // Since the FormData constructor invokes the formdata event it also
87 - // needs to be available before that happens so after construction it's too
88 - // late. We use a temporary fake node for the duration of this event.
89 - // TODO: FormData takes a second argument that it's the submitter but this
90 - // is fairly new so not all browsers support it yet. Switch to that technique
91 - // when available.
92 - const temp = submitter.ownerDocument.createElement('input');
93 - temp.name = submitter.name;
94 - temp.value = submitter.value;
95 - if (form.id) {
96 - temp.setAttribute('form', form.id);
126 + // An earlier event prevented form submission. If a transition update was
127 + // also scheduled, we should trigger a pending form status — even if
128 + // no action function was provided.
129 + if (didCurrentEventScheduleTransition()) {
130 + // We're going to set the pending form status, but because the submission
131 + // was prevented, we should not fire the action function.
132 + const formData = submitter
133 + ? createFormDataWithSubmitter(form, submitter)
134 + : new FormData(form);
135 + const pendingState: FormStatus = {
136 + pending: true,
137 + data: formData,
138 + method: form.method,
139 + action: action,
140 + };
141 + if (__DEV__) {
142 + Object.freeze(pendingState);
143 + }
144 + startHostTransition(
145 + formInst,
146 + pendingState,
147 + // Pass `null` as the action
148 + // TODO: Consider splitting up startHostTransition into two separate
149 + // functions, one that sets the form status and one that invokes
150 + // the action.
151 + null,
152 + formData,
153 + );
154 + } else {
155 + // No earlier event scheduled a transition. Exit without setting a
156 + // pending form status.
157 }
98 - (submitter.parentNode: any).insertBefore(temp, submitter);
99 - formData = new FormData(form);
100 - (temp.parentNode: any).removeChild(temp);
101 - } else {
102 - formData = new FormData(form);
103 - }
158 + } else if (typeof action === 'function') {
159 + // A form action was provided. Prevent native navigation.
160 + event.preventDefault();
161
105 - const pendingState: FormStatus = {
106 - pending: true,
107 - data: formData,
108 - method: form.method,
109 - action: action,
110 - };
111 - if (__DEV__) {
112 - Object.freeze(pendingState);
162 + // Dispatch the action and set a pending form status.
163 + const formData = submitter
164 + ? createFormDataWithSubmitter(form, submitter)
165 + : new FormData(form);
166 + const pendingState: FormStatus = {
167 + pending: true,
168 + data: formData,
169 + method: form.method,
170 + action: action,
171 + };
172 + if (__DEV__) {
173 + Object.freeze(pendingState);
174 + }
175 + startHostTransition(formInst, pendingState, action, formData);
176 + } else {
177 + // No earlier event prevented the default submission, and no action was
178 + // provided. Exit without setting a pending form status.
179 }
114 - startHostTransition(formInst, pendingState, action, formData);
180 }
181
182 dispatchQueue.push({
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
+1 -1
@@ -25,7 +25,7 @@ type FormStatusPending = {|
25 pending: true,
26 data: FormData,
27 method: string,
28 - action: string | (FormData => void | Promise<void>),
28 + action: string | (FormData => void | Promise<void>) | null,
29 |};
30
31 export type FormStatus = FormStatusPending | FormStatusNotPending;
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+307
@@ -35,10 +35,12 @@ describe('ReactDOMForm', () => {
35 let ReactDOMClient;
36 let Scheduler;
37 let assertLog;
38 + let assertConsoleErrorDev;
39 let waitForThrow;
40 let useState;
41 let Suspense;
42 let startTransition;
43 + let useTransition;
44 let use;
45 let textCache;
46 let useFormStatus;
@@ -54,9 +56,12 @@ describe('ReactDOMForm', () => {
56 act = require('internal-test-utils').act;
57 assertLog = require('internal-test-utils').assertLog;
58 waitForThrow = require('internal-test-utils').waitForThrow;
59 + assertConsoleErrorDev =
60 + require('internal-test-utils').assertConsoleErrorDev;
61 useState = React.useState;
62 Suspense = React.Suspense;
63 startTransition = React.startTransition;
64 + useTransition = React.useTransition;
65 use = React.use;
66 useFormStatus = ReactDOM.useFormStatus;
67 requestFormReset = ReactDOM.requestFormReset;
@@ -1782,4 +1787,306 @@ describe('ReactDOMForm', () => {
1787 // The form was reset even though the action didn't finish.
1788 expect(inputRef.current.value).toBe('Initial');
1789 });
1790 +
1791 + test("regression: submitter's formAction prop is coerced correctly before checking if it exists", async () => {
1792 + function App({submitterAction}) {
1793 + return (
1794 + <form action={() => Scheduler.log('Form action')}>
1795 + <button ref={buttonRef} type="submit" formAction={submitterAction} />
1796 + </form>
1797 + );
1798 + }
1799 +
1800 + const buttonRef = React.createRef();
1801 + const root = ReactDOMClient.createRoot(container);
1802 +
1803 + await act(() =>
1804 + root.render(
1805 + <App submitterAction={() => Scheduler.log('Button action')} />,
1806 + ),
1807 + );
1808 + await submit(buttonRef.current);
1809 + assertLog(['Button action']);
1810 +
1811 + // When there's no button action, the form action should fire
1812 + await act(() => root.render(<App submitterAction={null} />));
1813 + await submit(buttonRef.current);
1814 + assertLog(['Form action']);
1815 +
1816 + // Symbols are coerced to null, so this should fire the form action
1817 + await act(() => root.render(<App submitterAction={Symbol()} />));
1818 + assertConsoleErrorDev(['Invalid value for prop `formAction`']);
1819 + await submit(buttonRef.current);
1820 + assertLog(['Form action']);
1821 +
1822 + // Booleans are coerced to null, so this should fire the form action
1823 + await act(() => root.render(<App submitterAction={true} />));
1824 + await submit(buttonRef.current);
1825 + assertLog(['Form action']);
1826 +
1827 + // A string on the submitter should prevent the form action from firing
1828 + // and trigger the native behavior
1829 + await act(() => root.render(<App submitterAction="https://react.dev/" />));
1830 + await expect(submit(buttonRef.current)).rejects.toThrow(
1831 + 'Navigate to: https://react.dev/',
1832 + );
1833 + });
1834 +
1835 + test(
1836 + 'useFormStatus is activated if startTransition is called ' +
1837 + 'inside preventDefault-ed submit event',
1838 + async () => {
1839 + function Output({value}) {
1840 + const {pending} = useFormStatus();
1841 + return <Text text={pending ? `${value} (pending...)` : value} />;
1842 + }
1843 +
1844 + function App({value}) {
1845 + const [, startFormTransition] = useTransition();
1846 +
1847 + function onSubmit(event) {
1848 + event.preventDefault();
1849 + startFormTransition(async () => {
1850 + const updatedValue = event.target.elements.search.value;
1851 + Scheduler.log('Action started');
1852 + await getText('Wait');
1853 + Scheduler.log('Action finished');
1854 + startTransition(() => root.render(<App value={updatedValue} />));
1855 + });
1856 + }
1857 + return (
1858 + <form ref={formRef} onSubmit={onSubmit}>
1859 + <input
1860 + ref={inputRef}
1861 + type="text"
1862 + name="search"
1863 + defaultValue={value}
1864 + />
1865 + <div ref={outputRef}>
1866 + <Output value={value} />
1867 + </div>
1868 + </form>
1869 + );
1870 + }
1871 +
1872 + const formRef = React.createRef();
1873 + const inputRef = React.createRef();
1874 + const outputRef = React.createRef();
1875 + const root = ReactDOMClient.createRoot(container);
1876 + await act(() => root.render(<App value="Initial" />));
1877 + assertLog(['Initial']);
1878 +
1879 + // Update the input to something different
1880 + inputRef.current.value = 'Updated';
1881 +
1882 + // Submit the form.
1883 + await submit(formRef.current);
1884 + // The form switches into a pending state.
1885 + assertLog(['Action started', 'Initial (pending...)']);
1886 + expect(outputRef.current.textContent).toBe('Initial (pending...)');
1887 +
1888 + // While the submission is still pending, update the input again so we
1889 + // can check whether the form is reset after the action finishes.
1890 + inputRef.current.value = 'Updated again after submission';
1891 +
1892 + // Resolve the async action
1893 + await act(() => resolveText('Wait'));
1894 + assertLog(['Action finished', 'Updated']);
1895 + expect(outputRef.current.textContent).toBe('Updated');
1896 +
1897 + // Confirm that the form was not automatically reset (should call
1898 + // requestFormReset(formRef.current) to opt into this behavior)
1899 + expect(inputRef.current.value).toBe('Updated again after submission');
1900 + },
1901 + );
1902 +
1903 + test('useFormStatus is not activated if startTransition is not called', async () => {
1904 + function Output({value}) {
1905 + const {pending} = useFormStatus();
1906 +
1907 + return (
1908 + <Text
1909 + text={
1910 + pending
1911 + ? 'Should be unreachable! This test should never activate the pending state.'
1912 + : value
1913 + }
1914 + />
1915 + );
1916 + }
1917 +
1918 + function App({value}) {
1919 + async function onSubmit(event) {
1920 + event.preventDefault();
1921 + const updatedValue = event.target.elements.search.value;
1922 + Scheduler.log('Async event handler started');
1923 + await getText('Wait');
1924 + Scheduler.log('Async event handler finished');
1925 + startTransition(() => root.render(<App value={updatedValue} />));
1926 + }
1927 + return (
1928 + <form ref={formRef} onSubmit={onSubmit}>
1929 + <input
1930 + ref={inputRef}
1931 + type="text"
1932 + name="search"
1933 + defaultValue={value}
1934 + />
1935 + <div ref={outputRef}>
1936 + <Output value={value} />
1937 + </div>
1938 + </form>
1939 + );
1940 + }
1941 +
1942 + const formRef = React.createRef();
1943 + const inputRef = React.createRef();
1944 + const outputRef = React.createRef();
1945 + const root = ReactDOMClient.createRoot(container);
1946 + await act(() => root.render(<App value="Initial" />));
1947 + assertLog(['Initial']);
1948 +
1949 + // Update the input to something different
1950 + inputRef.current.value = 'Updated';
1951 +
1952 + // Submit the form.
1953 + await submit(formRef.current);
1954 + // Unlike the previous test, which uses startTransition to manually dispatch
1955 + // an action, this test uses a regular event handler, so useFormStatus is
1956 + // not activated.
1957 + assertLog(['Async event handler started']);
1958 + expect(outputRef.current.textContent).toBe('Initial');
1959 +
1960 + // While the submission is still pending, update the input again so we
1961 + // can check whether the form is reset after the action finishes.
1962 + inputRef.current.value = 'Updated again after submission';
1963 +
1964 + // Resolve the async action
1965 + await act(() => resolveText('Wait'));
1966 + assertLog(['Async event handler finished', 'Updated']);
1967 + expect(outputRef.current.textContent).toBe('Updated');
1968 +
1969 + // Confirm that the form was not automatically reset (should call
1970 + // requestFormReset(formRef.current) to opt into this behavior)
1971 + expect(inputRef.current.value).toBe('Updated again after submission');
1972 + });
1973 +
1974 + test('useFormStatus is not activated if event is not preventDefault-ed ', async () => {
1975 + function Output({value}) {
1976 + const {pending} = useFormStatus();
1977 + return <Text text={pending ? `${value} (pending...)` : value} />;
1978 + }
1979 +
1980 + function App({value}) {
1981 + const [, startFormTransition] = useTransition();
1982 +
1983 + function onSubmit(event) {
1984 + // This event is not preventDefault-ed, so the default form submission
1985 + // happens, and useFormStatus is not activated.
1986 + startFormTransition(async () => {
1987 + const updatedValue = event.target.elements.search.value;
1988 + Scheduler.log('Action started');
1989 + await getText('Wait');
1990 + Scheduler.log('Action finished');
1991 + startTransition(() => root.render(<App value={updatedValue} />));
1992 + });
1993 + }
1994 + return (
1995 + <form ref={formRef} onSubmit={onSubmit}>
1996 + <input
1997 + ref={inputRef}
1998 + type="text"
1999 + name="search"
2000 + defaultValue={value}
2001 + />
2002 + <div ref={outputRef}>
2003 + <Output value={value} />
2004 + </div>
2005 + </form>
2006 + );
2007 + }
2008 +
2009 + const formRef = React.createRef();
2010 + const inputRef = React.createRef();
2011 + const outputRef = React.createRef();
2012 + const root = ReactDOMClient.createRoot(container);
2013 + await act(() => root.render(<App value="Initial" />));
2014 + assertLog(['Initial']);
2015 +
2016 + // Update the input to something different
2017 + inputRef.current.value = 'Updated';
2018 +
2019 + // Submitting the form should trigger the default navigation behavior
2020 + await expect(submit(formRef.current)).rejects.toThrow(
2021 + 'Navigate to: http://localhost/',
2022 + );
2023 +
2024 + // The useFormStatus hook was not activated
2025 + assertLog(['Action started', 'Initial']);
2026 + expect(outputRef.current.textContent).toBe('Initial');
2027 + });
2028 +
2029 + test('useFormStatus coerces the value of the "action" prop', async () => {
2030 + function Status() {
2031 + const {pending, action} = useFormStatus();
2032 +
2033 + if (pending) {
2034 + Scheduler.log(action);
2035 + return 'Pending';
2036 + } else {
2037 + return 'Not pending';
2038 + }
2039 + }
2040 +
2041 + function Form({action}) {
2042 + const [, startFormTransition] = useTransition();
2043 +
2044 + function onSubmit(event) {
2045 + event.preventDefault();
2046 + // Schedule an empty action for no other purpose than to trigger the
2047 + // pending state.
2048 + startFormTransition(async () => {});
2049 + }
2050 + return (
2051 + <form ref={formRef} action={action} onSubmit={onSubmit}>
2052 + <Status />
2053 + </form>
2054 + );
2055 + }
2056 +
2057 + const formRef = React.createRef();
2058 + const root = ReactDOMClient.createRoot(container);
2059 +
2060 + // Symbols are coerced to null
2061 + await act(() => root.render(<Form action={Symbol()} />));
2062 + assertConsoleErrorDev(['Invalid value for prop `action`']);
2063 + await submit(formRef.current);
2064 + assertLog([null]);
2065 +
2066 + // Booleans are coerced to null
2067 + await act(() => root.render(<Form action={true} />));
2068 + await submit(formRef.current);
2069 + assertLog([null]);
2070 +
2071 + // Strings are passed through
2072 + await act(() => root.render(<Form action="https://react.dev" />));
2073 + await submit(formRef.current);
2074 + assertLog(['https://react.dev']);
2075 +
2076 + // Functions are passed through
2077 + const actionFn = () => {};
2078 + await act(() => root.render(<Form action={actionFn} />));
2079 + await submit(formRef.current);
2080 + assertLog([actionFn]);
2081 +
2082 + // Everything else is toString-ed
2083 + class MyAction {
2084 + toString() {
2085 + return 'stringified action';
2086 + }
2087 + }
2088 + await act(() => root.render(<Form action={new MyAction()} />));
2089 + await submit(formRef.current);
2090 + assertLog(['stringified action']);
2091 + });
2092 });
packages/react-reconciler/src/ReactFiberHooks.js
+19 -9
@@ -2944,16 +2944,20 @@ function startTransition<S>(
2944 }
2945 }
2946
2947 +const noop = () => {};
2948 +
2949 export function startHostTransition<F>(
2950 formFiber: Fiber,
2951 pendingState: TransitionStatus,
2950 - callback: F => mixed,
2952 + action: (F => mixed) | null,
2953 formData: F,
2954 ): void {
2955 if (!enableAsyncActions) {
2956 // Form actions are enabled, but async actions are not. Call the function,
2957 // but don't handle any pending or error states.
2956 - callback(formData);
2958 + if (action !== null) {
2959 + action(formData);
2960 + }
2961 return;
2962 }
2963
@@ -2976,13 +2980,19 @@ export function startHostTransition<F>(
2980 queue,
2981 pendingState,
2982 NoPendingHostTransition,
2979 - // TODO: We can avoid this extra wrapper, somehow. Figure out layering
2980 - // once more of this function is implemented.
2981 - () => {
2982 - // Automatically reset the form when the action completes.
2983 - requestFormReset(formFiber);
2984 - return callback(formData);
2985 - },
2983 + // TODO: `startTransition` both sets the pending state and dispatches
2984 + // the action, if one is provided. Consider refactoring these two
2985 + // concerns to avoid the extra lambda.
2986 +
2987 + action === null
2988 + ? // No action was provided, but we still call `startTransition` to
2989 + // set the pending form status.
2990 + noop
2991 + : () => {
2992 + // Automatically reset the form when the action completes.
2993 + requestFormReset(formFiber);
2994 + return action(formData);
2995 + },
2996 );
2997 }
2998
packages/react-reconciler/src/ReactFiberRootScheduler.js
+4
@@ -482,3 +482,7 @@ export function requestTransitionLane(
482 }
483 return currentEventTransitionLane;
484 }
485 +
486 +export function didCurrentEventScheduleTransition(): boolean {
487 + return currentEventTransitionLane !== NoLane;
488 +}