Repository: facebook/react
Pull request: Compare name when hydrating hidden fields to filter out extra form action fields

This solves an issue where if you inject a hidden field in the beginning of the form, we might mistakenly hydrate the injected one that was part of an action.

I'm not too happy about how specific this becomes. It's similar to Float but in general we don't do this deep comparison.

See https://github.com/vercel/next.js/issues/50087

Changed files:
filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -1068,11 +1068,21 @@ export function canHydrateInstance(
       if (
         enableFormActions &&
         type === 'input' &&
-        (element: any).type === 'hidden' &&
-        anyProps.type !== 'hidden'
+        (element: any).type === 'hidden'
       ) {
-        // Skip past hidden inputs unless that's what we're looking for. This allows us
-        // embed extra form data in the original form.
+        if (__DEV__) {
+          checkAttributeStringCoercion(anyProps.name, 'name');
+        }
+        const name = anyProps.name == null ? null : '' + anyProps.name;
+        if (
+          anyProps.type !== 'hidden' ||
+          element.getAttribute('name') !== name
+        ) {
+          // Skip past hidden inputs unless that's what we're looking for. This allows us
+          // embed extra form data in the original form.
+        } else {
+          return element;
+        }
       } else {
         return element;
       }

filename: packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
patch: @@ -524,6 +524,7 @@ describe('ReactDOMFizzForm', () => {
 
   // @gate enableFormActions
   it('can provide a custom action on buttons the server for actions', async () => {
+    const hiddenRef = React.createRef();
     const inputRef = React.createRef();
     const buttonRef = React.createRef();
     let foo;
@@ -546,7 +547,7 @@ describe('ReactDOMFizzForm', () => {
     function App() {
       return (
         <form>
-          <input type="hidden" name="foo" value="bar" />
+          <input type="hidden" name="foo" value="bar" ref={hiddenRef} />
           <input
             type="submit"
             formAction={action}
@@ -588,6 +589,8 @@ describe('ReactDOMFizzForm', () => {
       ReactDOMClient.hydrateRoot(container, <App />);
     });
 
+    expect(hiddenRef.current.name).toBe('foo');
+
     submit(inputRef.current);
 
     expect(foo).toBe('bar');
@@ -598,4 +601,53 @@ describe('ReactDOMFizzForm', () => {
 
     expect(foo).toBe('bar');
   });
+
+  // @gate enableFormActions
+  it('can hydrate hidden fields in the beginning of a form', async () => {
+    const hiddenRef = React.createRef();
+
+    let invoked = false;
+    function action(formData) {
+      invoked = true;
+    }
+    action.$$FORM_ACTION = function (identifierPrefix) {
+      const extraFields = new FormData();
+      extraFields.append(identifierPrefix + 'hello', 'world');
+      return {
+        action: '',
+        name: identifierPrefix,
+        method: 'POST',
+        encType: 'multipart/form-data',
+        data: extraFields,
+      };
+    };
+    function App() {
+      return (
+        <form action={action}>
+          <input type="hidden" name="bar" defaultValue="baz" ref={hiddenRef} />
+          <input type="text" name="foo" defaultValue="bar" />
+        </form>
+      );
+    }
+
+    const stream = await ReactDOMServer.renderToReadableStream(<App />);
+    await readIntoContainer(stream);
+
+    const barField = container.querySelector('[name=bar]');
+
+    await act(async () => {
+      ReactDOMClient.hydrateRoot(container, <App />);
+    });
+
+    expect(hiddenRef.current).toBe(barField);
+
+    expect(hiddenRef.current.name).toBe('bar');
+    expect(hiddenRef.current.value).toBe('baz');
+
+    expect(container.querySelectorAll('[name=bar]').length).toBe(1);
+
+    submit(hiddenRef.current.form);
+
+    expect(invoked).toBe(true);
+  });
 });