Repository: facebook/react
Pull request: Insert temporary input node to polyfill submitter argument in FormData

Insert temporary input node to polyfill submitter argument in FormData. This works for buttons too and fixes a bug where the type attribute wasn't reset.

I also exclude the submitter if it's a function action. This ensures that we don't include the generated "name" when the action is a server action. Conceptually that name doesn't exist.


Changed files:
filename: packages/react-dom-bindings/src/client/ReactDOMComponent.js
patch: @@ -132,6 +132,9 @@ function validateFormActionInDevelopment(
   props: any,
 ) {
   if (__DEV__) {
+    if (value == null) {
+      return;
+    }
     if (tag === 'form') {
       if (key === 'formAction') {
         console.error(
@@ -483,6 +486,9 @@ function setProp(
     case 'action':
     case 'formAction': {
       // TODO: Consider moving these special cases to the form, input and button tags.
+      if (__DEV__) {
+        validateFormActionInDevelopment(tag, key, value, props);
+      }
       if (enableFormActions) {
         if (typeof value === 'function') {
           // Set a javascript URL that doesn't do anything. We don't expect this to be invoked
@@ -554,9 +560,6 @@ function setProp(
         domElement.removeAttribute(key);
         break;
       }
-      if (__DEV__) {
-        validateFormActionInDevelopment(tag, key, value, props);
-      }
       // `setAttribute` with objects becomes only `[object]` in IE8/9,
       // ('' + value) makes it output the correct toString()-value.
       if (__DEV__) {

filename: packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js
patch: @@ -42,7 +42,7 @@ function extractEvents(
   const formInst = maybeTargetInst;
   const form: HTMLFormElement = (nativeEventTarget: any);
   let action = (getFiberCurrentPropsFromNode(form): any).action;
-  const submitter: null | HTMLInputElement | HTMLButtonElement =
+  let submitter: null | HTMLInputElement | HTMLButtonElement =
     (nativeEvent: any).submitter;
   let submitterAction;
   if (submitter) {
@@ -53,6 +53,9 @@ function extractEvents(
     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
+      // value to the FormData since it's controlled by the server.
+      submitter = null;
     }
   }
 
@@ -81,18 +84,16 @@ function extractEvents(
       // 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. The easiest way to do this is to switch the form field to hidden,
-      // which is always included, and then back again. This does means that this
-      // is observable from the formdata event though.
-      // TODO: This tricky doesn't work on button elements. Consider inserting
-      // a fake node instead for that case.
+      // 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 type = submitter.type;
-      submitter.type = 'hidden';
+      const temp = submitter.ownerDocument.createElement('input');
+      temp.name = submitter.name;
+      temp.value = submitter.value;
+      (submitter.parentNode: any).insertBefore(temp, submitter);
       formData = new FormData(form);
-      submitter.type = type;
+      (temp.parentNode: any).removeChild(temp);
     } else {
       formData = new FormData(form);
     }

filename: packages/react-dom/src/__tests__/ReactDOMForm-test.js
patch: @@ -475,7 +475,8 @@ describe('ReactDOMForm', () => {
 
   // @gate enableFormActions
   it('can read the clicked button in the formdata event', async () => {
-    const ref = React.createRef();
+    const inputRef = React.createRef();
+    const buttonRef = React.createRef();
     let button;
     let title;
 
@@ -487,11 +488,13 @@ describe('ReactDOMForm', () => {
     const root = ReactDOMClient.createRoot(container);
     await act(async () => {
       root.render(
-        // TODO: Test button element too.
         <form action={action}>
           <input type="text" name="title" defaultValue="hello" />
           <input type="submit" name="button" value="save" />
-          <input type="submit" name="button" value="delete" ref={ref} />
+          <input type="submit" name="button" value="delete" ref={inputRef} />
+          <button name="button" value="edit" ref={buttonRef}>
+            Edit
+          </button>
         </form>,
       );
     });
@@ -503,10 +506,70 @@ describe('ReactDOMForm', () => {
       }
     });
 
-    await submit(ref.current);
+    await submit(inputRef.current);
 
     expect(button).toBe('delete');
     expect(title).toBe(null);
+
+    await submit(buttonRef.current);
+
+    expect(button).toBe('edit');
+    expect(title).toBe('hello');
+
+    // Ensure that the type field got correctly restored
+    expect(inputRef.current.getAttribute('type')).toBe('submit');
+    expect(buttonRef.current.getAttribute('type')).toBe(null);
+  });
+
+  // @gate enableFormActions
+  it('excludes the submitter name when the submitter is a function action', async () => {
+    const inputRef = React.createRef();
+    const buttonRef = React.createRef();
+    let button;
+
+    function action(formData) {
+      // A function action cannot control the name since it might be controlled by the server
+      // so we need to make sure it doesn't get into the FormData.
+      button = formData.get('button');
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    await expect(async () => {
+      await act(async () => {
+        root.render(
+          <form>
+            <input
+              type="submit"
+              name="button"
+              value="delete"
+              ref={inputRef}
+              formAction={action}
+            />
+            <button
+              name="button"
+              value="edit"
+              ref={buttonRef}
+              formAction={action}>
+              Edit
+            </button>
+          </form>,
+        );
+      });
+    }).toErrorDev([
+      'Cannot specify a "name" prop for a button that specifies a function as a formAction.',
+    ]);
+
+    await submit(inputRef.current);
+
+    expect(button).toBe(null);
+
+    await submit(buttonRef.current);
+
+    expect(button).toBe(null);
+
+    // Ensure that the type field got correctly restored
+    expect(inputRef.current.getAttribute('type')).toBe('submit');
+    expect(buttonRef.current.getAttribute('type')).toBe(null);
   });
 
   // @gate enableFormActions || !__DEV__