Skip to content

Commit c334563

Browse files
authored
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); }); }} /> ```
1 parent 151cce3 commit c334563

File tree

5 files changed

+440
-54
lines changed

5 files changed

+440
-54
lines changed

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

Lines changed: 109 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,61 @@ import type {EventSystemFlags} from '../EventSystemFlags';
1414
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
1515
import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
1616

17+
import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
1718
import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
1819
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';
1923

2024
import {SyntheticEvent} from '../SyntheticEvent';
2125

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+
2272
/**
2373
* This plugin invokes action functions on forms, inputs and buttons if
2474
* the form doesn't prevent default.
@@ -42,16 +92,19 @@ function extractEvents(
4292
}
4393
const formInst = maybeTargetInst;
4494
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 =
4799
(nativeEvent: any).submitter;
48100
let submitterAction;
49101
if (submitter) {
50102
const submitterProps = getFiberCurrentPropsFromNode(submitter);
51103
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) {
55108
// The submitter overrides the form action.
56109
action = submitterAction;
57110
// If the action is a function, we don't want to pass its name
@@ -60,10 +113,6 @@ function extractEvents(
60113
}
61114
}
62115

63-
if (typeof action !== 'function') {
64-
return;
65-
}
66-
67116
const event = new SyntheticEvent(
68117
'action',
69118
'action',
@@ -74,44 +123,60 @@ function extractEvents(
74123

75124
function submitForm() {
76125
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.
97157
}
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();
104161

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.
113179
}
114-
startHostTransition(formInst, pendingState, action, formData);
115180
}
116181

117182
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)