diff --git a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js index 8f874e2cdb8c4..496b55e15e79b 100644 --- a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js @@ -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) | 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({ diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js index 0b3f478e33b19..ab36fc11199b1 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -25,7 +25,7 @@ type FormStatusPending = {| pending: true, data: FormData, method: string, - action: string | (FormData => void | Promise), + action: string | (FormData => void | Promise) | null, |}; export type FormStatus = FormStatusPending | FormStatusNotPending; diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 5db3b2fd8a5be..f4dda30c45143 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -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 ( +
Scheduler.log('Form action')}> +