Skip to content

Commit a23e517

Browse files
committed
useFormStatus in progressively-enhanced forms
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: ```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); }); }} /> ```
1 parent 3081f94 commit a23e517

File tree

5 files changed

+366
-52
lines changed

5 files changed

+366
-52
lines changed

packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js

Lines changed: 82 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions
1717
import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
1818
import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
1919
import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler';
20+
import {didCurrentEventScheduleTransition} from 'react-reconciler/src/ReactFiberRootScheduler';
2021
import sanitizeURL from 'react-dom-bindings/src/shared/sanitizeURL';
2122
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
2223

@@ -44,6 +45,30 @@ function coerceFormActionProp(
4445
}
4546
}
4647

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+
4772
/**
4873
* This plugin invokes action functions on forms, inputs and buttons if
4974
* the form doesn't prevent default.
@@ -67,15 +92,18 @@ function extractEvents(
6792
}
6893
const formInst = maybeTargetInst;
6994
const form: HTMLFormElement = (nativeEventTarget: any);
70-
let action = (getFiberCurrentPropsFromNode(form): any).action;
71-
let submitter: null | HTMLInputElement | HTMLButtonElement =
95+
let action = coerceFormActionProp(
96+
(getFiberCurrentPropsFromNode(form): any).action,
97+
);
98+
let submitter: null | void | HTMLInputElement | HTMLButtonElement =
7299
(nativeEvent: any).submitter;
73100
let submitterAction;
74101
if (submitter) {
75102
const submitterProps = getFiberCurrentPropsFromNode(submitter);
76103
submitterAction = submitterProps
77104
? coerceFormActionProp((submitterProps: any).formAction)
78-
: submitter.getAttribute('formAction');
105+
: // The built-in Flow type is ?string, wider than the spec
106+
((submitter.getAttribute('formAction'): any): string | null);
79107
if (submitterAction !== null) {
80108
// The submitter overrides the form action.
81109
action = submitterAction;
@@ -85,10 +113,6 @@ function extractEvents(
85113
}
86114
}
87115

88-
if (typeof action !== 'function') {
89-
return;
90-
}
91-
92116
const event = new SyntheticEvent(
93117
'action',
94118
'action',
@@ -99,44 +123,60 @@ function extractEvents(
99123

100124
function submitForm() {
101125
if (nativeEvent.defaultPrevented) {
102-
// We let earlier events to prevent the action from submitting.
103-
return;
104-
}
105-
// Prevent native navigation.
106-
event.preventDefault();
107-
let formData;
108-
if (submitter) {
109-
// The submitter's value should be included in the FormData.
110-
// It should be in the document order in the form.
111-
// Since the FormData constructor invokes the formdata event it also
112-
// needs to be available before that happens so after construction it's too
113-
// late. We use a temporary fake node for the duration of this event.
114-
// TODO: FormData takes a second argument that it's the submitter but this
115-
// is fairly new so not all browsers support it yet. Switch to that technique
116-
// when available.
117-
const temp = submitter.ownerDocument.createElement('input');
118-
temp.name = submitter.name;
119-
temp.value = submitter.value;
120-
if (form.id) {
121-
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.
122157
}
123-
(submitter.parentNode: any).insertBefore(temp, submitter);
124-
formData = new FormData(form);
125-
(temp.parentNode: any).removeChild(temp);
126-
} else {
127-
formData = new FormData(form);
128-
}
158+
} else if (typeof action === 'function') {
159+
// A form action was provided. Prevent native navigation.
160+
event.preventDefault();
129161

130-
const pendingState: FormStatus = {
131-
pending: true,
132-
data: formData,
133-
method: form.method,
134-
action: action,
135-
};
136-
if (__DEV__) {
137-
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.
138179
}
139-
startHostTransition(formInst, pendingState, action, formData);
140180
}
141181

142182
dispatchQueue.push({

packages/react-dom-bindings/src/shared/ReactDOMFormActions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type FormStatusPending = {|
2525
pending: true,
2626
data: FormData,
2727
method: string,
28-
action: string | (FormData => void | Promise<void>),
28+
action: string | (FormData => void | Promise<void>) | null,
2929
|};
3030

3131
export type FormStatus = FormStatusPending | FormStatusNotPending;

0 commit comments

Comments
 (0)