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);
    });
  }}
/>
```
This commit is contained in:
Andrew Clark
2024-05-09 13:16:08 -04:00
committed by GitHub
parent 151cce3740
commit c3345638cb
5 changed files with 440 additions and 54 deletions

View File

@@ -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.
@@ -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
@@ -60,10 +113,6 @@ function extractEvents(
}
}
if (typeof action !== 'function') {
return;
}
const event = new SyntheticEvent(
'action',
'action',
@@ -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({

View File

@@ -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;

View File

@@ -35,10 +35,12 @@ describe('ReactDOMForm', () => {
let ReactDOMClient;
let Scheduler;
let assertLog;
let assertConsoleErrorDev;
let waitForThrow;
let useState;
let Suspense;
let startTransition;
let useTransition;
let use;
let textCache;
let useFormStatus;
@@ -54,9 +56,12 @@ describe('ReactDOMForm', () => {
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
waitForThrow = require('internal-test-utils').waitForThrow;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
useState = React.useState;
Suspense = React.Suspense;
startTransition = React.startTransition;
useTransition = React.useTransition;
use = React.use;
useFormStatus = ReactDOM.useFormStatus;
requestFormReset = ReactDOM.requestFormReset;
@@ -1782,4 +1787,306 @@ describe('ReactDOMForm', () => {
// The form was reset even though the action didn't finish.
expect(inputRef.current.value).toBe('Initial');
});
test("regression: submitter's formAction prop is coerced correctly before checking if it exists", async () => {
function App({submitterAction}) {
return (
<form action={() => Scheduler.log('Form action')}>
<button ref={buttonRef} type="submit" formAction={submitterAction} />
</form>
);
}
const buttonRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<App submitterAction={() => Scheduler.log('Button action')} />,
),
);
await submit(buttonRef.current);
assertLog(['Button action']);
// When there's no button action, the form action should fire
await act(() => root.render(<App submitterAction={null} />));
await submit(buttonRef.current);
assertLog(['Form action']);
// Symbols are coerced to null, so this should fire the form action
await act(() => root.render(<App submitterAction={Symbol()} />));
assertConsoleErrorDev(['Invalid value for prop `formAction`']);
await submit(buttonRef.current);
assertLog(['Form action']);
// Booleans are coerced to null, so this should fire the form action
await act(() => root.render(<App submitterAction={true} />));
await submit(buttonRef.current);
assertLog(['Form action']);
// A string on the submitter should prevent the form action from firing
// and trigger the native behavior
await act(() => root.render(<App submitterAction="https://react.dev/" />));
await expect(submit(buttonRef.current)).rejects.toThrow(
'Navigate to: https://react.dev/',
);
});
test(
'useFormStatus is activated if startTransition is called ' +
'inside preventDefault-ed submit event',
async () => {
function Output({value}) {
const {pending} = useFormStatus();
return <Text text={pending ? `${value} (pending...)` : value} />;
}
function App({value}) {
const [, startFormTransition] = useTransition();
function onSubmit(event) {
event.preventDefault();
startFormTransition(async () => {
const updatedValue = event.target.elements.search.value;
Scheduler.log('Action started');
await getText('Wait');
Scheduler.log('Action finished');
startTransition(() => root.render(<App value={updatedValue} />));
});
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input
ref={inputRef}
type="text"
name="search"
defaultValue={value}
/>
<div ref={outputRef}>
<Output value={value} />
</div>
</form>
);
}
const formRef = React.createRef();
const inputRef = React.createRef();
const outputRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App value="Initial" />));
assertLog(['Initial']);
// Update the input to something different
inputRef.current.value = 'Updated';
// Submit the form.
await submit(formRef.current);
// The form switches into a pending state.
assertLog(['Action started', 'Initial (pending...)']);
expect(outputRef.current.textContent).toBe('Initial (pending...)');
// While the submission is still pending, update the input again so we
// can check whether the form is reset after the action finishes.
inputRef.current.value = 'Updated again after submission';
// Resolve the async action
await act(() => resolveText('Wait'));
assertLog(['Action finished', 'Updated']);
expect(outputRef.current.textContent).toBe('Updated');
// Confirm that the form was not automatically reset (should call
// requestFormReset(formRef.current) to opt into this behavior)
expect(inputRef.current.value).toBe('Updated again after submission');
},
);
test('useFormStatus is not activated if startTransition is not called', async () => {
function Output({value}) {
const {pending} = useFormStatus();
return (
<Text
text={
pending
? 'Should be unreachable! This test should never activate the pending state.'
: value
}
/>
);
}
function App({value}) {
async function onSubmit(event) {
event.preventDefault();
const updatedValue = event.target.elements.search.value;
Scheduler.log('Async event handler started');
await getText('Wait');
Scheduler.log('Async event handler finished');
startTransition(() => root.render(<App value={updatedValue} />));
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input
ref={inputRef}
type="text"
name="search"
defaultValue={value}
/>
<div ref={outputRef}>
<Output value={value} />
</div>
</form>
);
}
const formRef = React.createRef();
const inputRef = React.createRef();
const outputRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App value="Initial" />));
assertLog(['Initial']);
// Update the input to something different
inputRef.current.value = 'Updated';
// Submit the form.
await submit(formRef.current);
// Unlike the previous test, which uses startTransition to manually dispatch
// an action, this test uses a regular event handler, so useFormStatus is
// not activated.
assertLog(['Async event handler started']);
expect(outputRef.current.textContent).toBe('Initial');
// While the submission is still pending, update the input again so we
// can check whether the form is reset after the action finishes.
inputRef.current.value = 'Updated again after submission';
// Resolve the async action
await act(() => resolveText('Wait'));
assertLog(['Async event handler finished', 'Updated']);
expect(outputRef.current.textContent).toBe('Updated');
// Confirm that the form was not automatically reset (should call
// requestFormReset(formRef.current) to opt into this behavior)
expect(inputRef.current.value).toBe('Updated again after submission');
});
test('useFormStatus is not activated if event is not preventDefault-ed ', async () => {
function Output({value}) {
const {pending} = useFormStatus();
return <Text text={pending ? `${value} (pending...)` : value} />;
}
function App({value}) {
const [, startFormTransition] = useTransition();
function onSubmit(event) {
// This event is not preventDefault-ed, so the default form submission
// happens, and useFormStatus is not activated.
startFormTransition(async () => {
const updatedValue = event.target.elements.search.value;
Scheduler.log('Action started');
await getText('Wait');
Scheduler.log('Action finished');
startTransition(() => root.render(<App value={updatedValue} />));
});
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input
ref={inputRef}
type="text"
name="search"
defaultValue={value}
/>
<div ref={outputRef}>
<Output value={value} />
</div>
</form>
);
}
const formRef = React.createRef();
const inputRef = React.createRef();
const outputRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App value="Initial" />));
assertLog(['Initial']);
// Update the input to something different
inputRef.current.value = 'Updated';
// Submitting the form should trigger the default navigation behavior
await expect(submit(formRef.current)).rejects.toThrow(
'Navigate to: http://localhost/',
);
// The useFormStatus hook was not activated
assertLog(['Action started', 'Initial']);
expect(outputRef.current.textContent).toBe('Initial');
});
test('useFormStatus coerces the value of the "action" prop', async () => {
function Status() {
const {pending, action} = useFormStatus();
if (pending) {
Scheduler.log(action);
return 'Pending';
} else {
return 'Not pending';
}
}
function Form({action}) {
const [, startFormTransition] = useTransition();
function onSubmit(event) {
event.preventDefault();
// Schedule an empty action for no other purpose than to trigger the
// pending state.
startFormTransition(async () => {});
}
return (
<form ref={formRef} action={action} onSubmit={onSubmit}>
<Status />
</form>
);
}
const formRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
// Symbols are coerced to null
await act(() => root.render(<Form action={Symbol()} />));
assertConsoleErrorDev(['Invalid value for prop `action`']);
await submit(formRef.current);
assertLog([null]);
// Booleans are coerced to null
await act(() => root.render(<Form action={true} />));
await submit(formRef.current);
assertLog([null]);
// Strings are passed through
await act(() => root.render(<Form action="https://react.dev" />));
await submit(formRef.current);
assertLog(['https://react.dev']);
// Functions are passed through
const actionFn = () => {};
await act(() => root.render(<Form action={actionFn} />));
await submit(formRef.current);
assertLog([actionFn]);
// Everything else is toString-ed
class MyAction {
toString() {
return 'stringified action';
}
}
await act(() => root.render(<Form action={new MyAction()} />));
await submit(formRef.current);
assertLog(['stringified action']);
});
});

View File

@@ -2944,16 +2944,20 @@ function startTransition<S>(
}
}
const noop = () => {};
export function startHostTransition<F>(
formFiber: Fiber,
pendingState: TransitionStatus,
callback: F => mixed,
action: (F => mixed) | null,
formData: F,
): void {
if (!enableAsyncActions) {
// Form actions are enabled, but async actions are not. Call the function,
// but don't handle any pending or error states.
callback(formData);
if (action !== null) {
action(formData);
}
return;
}
@@ -2976,13 +2980,19 @@ export function startHostTransition<F>(
queue,
pendingState,
NoPendingHostTransition,
// TODO: We can avoid this extra wrapper, somehow. Figure out layering
// once more of this function is implemented.
() => {
// Automatically reset the form when the action completes.
requestFormReset(formFiber);
return callback(formData);
},
// TODO: `startTransition` both sets the pending state and dispatches
// the action, if one is provided. Consider refactoring these two
// concerns to avoid the extra lambda.
action === null
? // No action was provided, but we still call `startTransition` to
// set the pending form status.
noop
: () => {
// Automatically reset the form when the action completes.
requestFormReset(formFiber);
return action(formData);
},
);
}

View File

@@ -482,3 +482,7 @@ export function requestTransitionLane(
}
return currentEventTransitionLane;
}
export function didCurrentEventScheduleTransition(): boolean {
return currentEventTransitionLane !== NoLane;
}