Skip to content

Commit

Permalink
Support useFormStatus in progressively-enhanced forms (#29019)
Browse files Browse the repository at this point in the history
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);
    });
  }}
/>
```
  • Loading branch information
acdlite committed May 9, 2024
1 parent 151cce3 commit c334563
Show file tree
Hide file tree
Showing 5 changed files with 440 additions and 54 deletions.
153 changes: 109 additions & 44 deletions packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js
Expand Up @@ -14,11 +14,61 @@ import type {EventSystemFlags} from '../EventSystemFlags';
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';

import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler';
import {didCurrentEventScheduleTransition} from 'react-reconciler/src/ReactFiberRootScheduler';
import sanitizeURL from 'react-dom-bindings/src/shared/sanitizeURL';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';

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

function coerceFormActionProp(
actionProp: mixed,
): string | (FormData => void | Promise<void>) | null {
// This should match the logic in ReactDOMComponent
if (
actionProp == null ||
typeof actionProp === 'symbol' ||
typeof actionProp === 'boolean'
) {
return null;
} else if (typeof actionProp === 'function') {
return (actionProp: any);
} else {
if (__DEV__) {
checkAttributeStringCoercion(actionProp, 'action');
}
return (sanitizeURL(
enableTrustedTypesIntegration ? actionProp : '' + (actionProp: any),
): any);
}
}

function createFormDataWithSubmitter(
form: HTMLFormElement,
submitter: HTMLInputElement | HTMLButtonElement,
) {
// The submitter's value should be included in the FormData.
// It should be in the document order in the form.
// Since the FormData constructor invokes the formdata event it also
// needs to be available before that happens so after construction it's too
// late. We use a temporary fake node for the duration of this event.
// TODO: FormData takes a second argument that it's the submitter but this
// is fairly new so not all browsers support it yet. Switch to that technique
// when available.
const temp = submitter.ownerDocument.createElement('input');
temp.name = submitter.name;
temp.value = submitter.value;
if (form.id) {
temp.setAttribute('form', form.id);
}
(submitter.parentNode: any).insertBefore(temp, submitter);
const formData = new FormData(form);
(temp.parentNode: any).removeChild(temp);
return formData;
}

/**
* This plugin invokes action functions on forms, inputs and buttons if
* the form doesn't prevent default.
Expand All @@ -42,16 +92,19 @@ function extractEvents(
}
const formInst = maybeTargetInst;
const form: HTMLFormElement = (nativeEventTarget: any);
let action = (getFiberCurrentPropsFromNode(form): any).action;
let submitter: null | HTMLInputElement | HTMLButtonElement =
let action = coerceFormActionProp(
(getFiberCurrentPropsFromNode(form): any).action,
);
let submitter: null | void | HTMLInputElement | HTMLButtonElement =
(nativeEvent: any).submitter;
let submitterAction;
if (submitter) {
const submitterProps = getFiberCurrentPropsFromNode(submitter);
submitterAction = submitterProps
? (submitterProps: any).formAction
: submitter.getAttribute('formAction');
if (submitterAction != null) {
? coerceFormActionProp((submitterProps: any).formAction)
: // The built-in Flow type is ?string, wider than the spec
((submitter.getAttribute('formAction'): any): string | null);
if (submitterAction !== null) {
// The submitter overrides the form action.
action = submitterAction;
// If the action is a function, we don't want to pass its name
Expand All @@ -60,10 +113,6 @@ function extractEvents(
}
}

if (typeof action !== 'function') {
return;
}

const event = new SyntheticEvent(
'action',
'action',
Expand All @@ -74,44 +123,60 @@ function extractEvents(

function submitForm() {
if (nativeEvent.defaultPrevented) {
// We let earlier events to prevent the action from submitting.
return;
}
// Prevent native navigation.
event.preventDefault();
let formData;
if (submitter) {
// The submitter's value should be included in the FormData.
// It should be in the document order in the form.
// Since the FormData constructor invokes the formdata event it also
// needs to be available before that happens so after construction it's too
// late. We use a temporary fake node for the duration of this event.
// TODO: FormData takes a second argument that it's the submitter but this
// is fairly new so not all browsers support it yet. Switch to that technique
// when available.
const temp = submitter.ownerDocument.createElement('input');
temp.name = submitter.name;
temp.value = submitter.value;
if (form.id) {
temp.setAttribute('form', form.id);
// An earlier event prevented form submission. If a transition update was
// also scheduled, we should trigger a pending form status — even if
// no action function was provided.
if (didCurrentEventScheduleTransition()) {
// We're going to set the pending form status, but because the submission
// was prevented, we should not fire the action function.
const formData = submitter
? createFormDataWithSubmitter(form, submitter)
: new FormData(form);
const pendingState: FormStatus = {
pending: true,
data: formData,
method: form.method,
action: action,
};
if (__DEV__) {
Object.freeze(pendingState);
}
startHostTransition(
formInst,
pendingState,
// Pass `null` as the action
// TODO: Consider splitting up startHostTransition into two separate
// functions, one that sets the form status and one that invokes
// the action.
null,
formData,
);
} else {
// No earlier event scheduled a transition. Exit without setting a
// pending form status.
}
(submitter.parentNode: any).insertBefore(temp, submitter);
formData = new FormData(form);
(temp.parentNode: any).removeChild(temp);
} else {
formData = new FormData(form);
}
} else if (typeof action === 'function') {
// A form action was provided. Prevent native navigation.
event.preventDefault();

const pendingState: FormStatus = {
pending: true,
data: formData,
method: form.method,
action: action,
};
if (__DEV__) {
Object.freeze(pendingState);
// Dispatch the action and set a pending form status.
const formData = submitter
? createFormDataWithSubmitter(form, submitter)
: new FormData(form);
const pendingState: FormStatus = {
pending: true,
data: formData,
method: form.method,
action: action,
};
if (__DEV__) {
Object.freeze(pendingState);
}
startHostTransition(formInst, pendingState, action, formData);
} else {
// No earlier event prevented the default submission, and no action was
// provided. Exit without setting a pending form status.
}
startHostTransition(formInst, pendingState, action, formData);
}

dispatchQueue.push({
Expand Down
Expand Up @@ -25,7 +25,7 @@ type FormStatusPending = {|
pending: true,
data: FormData,
method: string,
action: string | (FormData => void | Promise<void>),
action: string | (FormData => void | Promise<void>) | null,
|};

export type FormStatus = FormStatusPending | FormStatusNotPending;
Expand Down

0 comments on commit c334563

Please sign in to comment.