From 3b08beb22d5dc700b14077d62a938c2a5e508b43 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 9 Mar 2023 23:25:31 -0500 Subject: [PATCH 1/6] Add feature flag --- packages/shared/ReactFeatureFlags.js | 2 ++ packages/shared/forks/ReactFeatureFlags.native-fb.js | 1 + packages/shared/forks/ReactFeatureFlags.native-oss.js | 1 + packages/shared/forks/ReactFeatureFlags.test-renderer.js | 1 + packages/shared/forks/ReactFeatureFlags.test-renderer.native.js | 1 + packages/shared/forks/ReactFeatureFlags.test-renderer.www.js | 1 + packages/shared/forks/ReactFeatureFlags.www.js | 2 ++ 7 files changed, 9 insertions(+) diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 58500c8239738..bcf876aef8484 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -85,6 +85,8 @@ export const enableLegacyCache = __EXPERIMENTAL__; export const enableCacheElement = __EXPERIMENTAL__; export const enableFetchInstrumentation = true; +export const enableFormActions = __EXPERIMENTAL__; + export const enableTransitionTracing = false; // No known bugs, but needs performance testing diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index cc7dc4ef91d47..24e9e3c40d92e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -32,6 +32,7 @@ export const enableCache = false; export const enableLegacyCache = false; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Native export const enableSchedulerDebugging = false; export const debugRenderPhaseSideEffectsForStrictMode = true; export const disableJavaScriptURLs = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index ebb4f2b154db5..37d16534c83eb 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -23,6 +23,7 @@ export const enableCache = false; export const enableLegacyCache = false; export const enableCacheElement = false; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Native export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index b733725c9dc85..29953eae2fea8 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -23,6 +23,7 @@ export const enableCache = true; export const enableLegacyCache = __EXPERIMENTAL__; export const enableCacheElement = __EXPERIMENTAL__; export const enableFetchInstrumentation = true; +export const enableFormActions = true; // Doesn't affect Test Renderer export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 2554dd117a293..600ba0f0ed25c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -23,6 +23,7 @@ export const enableCache = true; export const enableLegacyCache = false; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Test Renderer export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 25a318e81cf24..5f68549b511ba 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -23,6 +23,7 @@ export const enableCache = true; export const enableLegacyCache = true; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; // Doesn't affect Test Renderer export const enableSchedulerDebugging = false; export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index d6143be018d9f..8a587e8d24c35 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -72,6 +72,8 @@ export const enableLegacyCache = true; export const enableCacheElement = true; export const enableFetchInstrumentation = false; +export const enableFormActions = true; + export const disableJavaScriptURLs = true; // TODO: www currently relies on this feature. It's disabled in open source. From fdecf9ee8c4817b66736c89b4e2793d206cfd1b9 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 5 Apr 2023 19:13:35 -0400 Subject: [PATCH 2/6] Client side form actions --- .../src/client/ReactDOMComponent.js | 178 +++++++- .../src/events/DOMPluginEventSystem.js | 13 + .../events/plugins/FormActionEventPlugin.js | 110 +++++ .../src/server/ReactFizzConfigDOM.js | 390 ++++++++++++++-- .../src/shared/ReactDOMUnknownPropertyHook.js | 20 +- .../src/__tests__/ReactDOMComponent-test.js | 18 +- .../src/__tests__/ReactDOMFizzForm-test.js | 198 +++++++++ .../src/__tests__/ReactDOMForm-test.js | 417 ++++++++++++++++++ ...ctDOMServerIntegrationUntrustedURL-test.js | 17 +- 9 files changed, 1297 insertions(+), 64 deletions(-) create mode 100644 packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMForm-test.js diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index e37c61a35dc97..fcbaf116bb07e 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -65,6 +65,7 @@ import sanitizeURL from '../shared/sanitizeURL'; import { enableCustomElementPropertySupport, enableClientRenderFallbackOnTextMismatch, + enableFormActions, enableHostSingletons, disableIEWorkarounds, enableTrustedTypesIntegration, @@ -79,6 +80,10 @@ import { let didWarnControlledToUncontrolled = false; let didWarnUncontrolledToControlled = false; let didWarnInvalidHydration = false; +let didWarnFormActionType = false; +let didWarnFormActionName = false; +let didWarnFormActionTarget = false; +let didWarnFormActionMethod = false; let canDiffStyleForHydrationWarning; if (__DEV__) { // IE 11 parses & normalizes the style attribute as opposed to other @@ -116,6 +121,102 @@ function validatePropertiesInDevelopment(type: string, props: any) { } } +function validateFormActionInDevelopment( + tag: string, + key: string, + value: mixed, + props: any, +) { + if (__DEV__) { + if (tag === 'form') { + if (key === 'formAction') { + console.error( + 'You can only pass the formAction prop to or + + ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe(null); + + submit(inputRef.current); + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe(null); + savedTitle = null; + + submit(buttonRef.current); + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe('Hello'); + deletedTitle = null; + + expect(rootActionCalled).toBe(false); + }); + + // @gate enableFormActions || !__DEV__ + it('should warn when passing a function action during SSR and string during hydration', async () => { + function action(formData) {} + function App({isClient}) { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await expect(async () => { + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + }).toErrorDev( + 'Prop `action` did not match. Server: "function" Client: "action"', + ); + }); + + // @gate enableFormActions || !__DEV__ + it('should warn when passing a string during SSR and function during hydration', async () => { + function action(formData) {} + function App({isClient}) { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await expect(async () => { + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + }).toErrorDev( + 'Prop `action` did not match. Server: "action" Client: "function action(formData) {}"', + ); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js new file mode 100644 index 0000000000000..cc14655d3160b --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -0,0 +1,417 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +global.IS_REACT_ACT_ENVIRONMENT = true; + +// Our current version of JSDOM doesn't implement the event dispatching +// so we polyfill it. +const NativeFormData = global.FormData; +const FormDataPolyfill = function FormData(form) { + const formData = new NativeFormData(form); + const formDataEvent = new Event('formdata', { + bubbles: true, + cancelable: false, + }); + formDataEvent.formData = formData; + form.dispatchEvent(formDataEvent); + return formData; +}; +NativeFormData.prototype.constructor = FormDataPolyfill; +global.FormData = FormDataPolyfill; + +describe('ReactDOMForm', () => { + let act; + let container; + let React; + let ReactDOM; + let ReactDOMClient; + + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function submit(submitter) { + const form = submitter.form || submitter; + if (!submitter.form) { + submitter = undefined; + } + const submitEvent = new Event('submit', {bubbles: true, cancelable: true}); + submitEvent.submitter = submitter; + const returnValue = form.dispatchEvent(submitEvent); + if (!returnValue) { + return; + } + const action = + (submitter && submitter.getAttribute('formaction')) || form.action; + if (!/\s*javascript:/i.test(action)) { + throw new Error('Navigate to: ' + action); + } + } + + // @gate enableFormActions + it('should allow passing a function to form action', async () => { + const ref = React.createRef(); + let foo; + + function action(formData) { + foo = formData.get('foo'); + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
+ +
, + ); + }); + + submit(ref.current); + + expect(foo).toBe('bar'); + + // Try updating the action + + function action2(formData) { + foo = formData.get('foo') + '2'; + } + + await act(async () => { + root.render( +
+ +
, + ); + }); + + submit(ref.current); + + expect(foo).toBe('bar2'); + }); + + // @gate enableFormActions + it('should allow passing a function to an input/button formAction', async () => { + const inputRef = React.createRef(); + const buttonRef = React.createRef(); + let rootActionCalled = false; + let savedTitle = null; + let deletedTitle = null; + + function action(formData) { + rootActionCalled = true; + } + + function saveItem(formData) { + savedTitle = formData.get('title'); + } + + function deleteItem(formData) { + deletedTitle = formData.get('title'); + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
+ + + +
, + ); + }); + + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe(null); + + submit(inputRef.current); + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe(null); + savedTitle = null; + + submit(buttonRef.current); + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe('Hello'); + deletedTitle = null; + + // Try updating the actions + + function saveItem2(formData) { + savedTitle = formData.get('title') + '2'; + } + + function deleteItem2(formData) { + deletedTitle = formData.get('title') + '2'; + } + + await act(async () => { + root.render( +
+ + + +
, + ); + }); + + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe(null); + + submit(inputRef.current); + expect(savedTitle).toBe('Hello2'); + expect(deletedTitle).toBe(null); + savedTitle = null; + + submit(buttonRef.current); + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe('Hello2'); + + expect(rootActionCalled).toBe(false); + }); + + // @gate enableFormActions || !__DEV__ + it('should allow preventing default to block the action', async () => { + const ref = React.createRef(); + let actionCalled = false; + + function action(formData) { + actionCalled = true; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
e.preventDefault()}> + +
, + ); + }); + + submit(ref.current); + + expect(actionCalled).toBe(false); + }); + + // @gate enableFormActions + it('should only submit the inner of nested forms', async () => { + const ref = React.createRef(); + let data; + + function outerAction(formData) { + data = formData.get('data') + 'outer'; + } + function innerAction(formData) { + data = formData.get('data') + 'inner'; + } + + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(async () => { + // This isn't valid HTML but just in case. + root.render( +
+ + + +
+ , + ); + }); + }).toErrorDev([ + 'Warning: validateDOMNesting(...):
cannot appear as a descendant of .' + + '\n in form (at **)' + + '\n in form (at **)', + ]); + + submit(ref.current); + + expect(data).toBe('innerinner'); + }); + + // @gate enableFormActions + it('should only submit once if one root is nested inside the other', async () => { + const ref = React.createRef(); + let outerCalled = 0; + let innerCalled = 0; + let bubbledSubmit = false; + + function outerAction(formData) { + outerCalled++; + } + + function innerAction(formData) { + innerCalled++; + } + + const innerContainerRef = React.createRef(); + const outerRoot = ReactDOMClient.createRoot(container); + await act(async () => { + outerRoot.render( + // Nesting forms isn't valid HTML but just in case. +
(bubbledSubmit = true)}> + +
+ +
, + ); + }); + + const innerRoot = ReactDOMClient.createRoot(innerContainerRef.current); + await act(async () => { + innerRoot.render( +
+ +
, + ); + }); + + submit(ref.current); + + expect(bubbledSubmit).toBe(true); + expect(outerCalled).toBe(0); + expect(innerCalled).toBe(1); + }); + + // @gate enableFormActions + it('should only submit once if a portal is nested inside its own root', async () => { + const ref = React.createRef(); + let outerCalled = 0; + let innerCalled = 0; + let bubbledSubmit = false; + + function outerAction(formData) { + outerCalled++; + } + + function innerAction(formData) { + innerCalled++; + } + + const innerContainer = document.createElement('div'); + const innerContainerRef = React.createRef(); + const outerRoot = ReactDOMClient.createRoot(container); + await act(async () => { + outerRoot.render( + // Nesting forms isn't valid HTML but just in case. +
(bubbledSubmit = true)}> +
+
+ {ReactDOM.createPortal( + + + , + innerContainer, + )} + +
, + ); + }); + + innerContainerRef.current.appendChild(innerContainer); + + submit(ref.current); + + expect(bubbledSubmit).toBe(true); + expect(outerCalled).toBe(0); + expect(innerCalled).toBe(1); + }); + + // @gate enableFormActions + it('can read the clicked button in the formdata event', async () => { + const ref = React.createRef(); + let button; + let title; + + function action(formData) { + button = formData.get('button'); + title = formData.get('title'); + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( + // TODO: Test button element too. +
+ + + +
, + ); + }); + + container.addEventListener('formdata', e => { + // Process in the formdata event somehow + if (e.formData.get('button') === 'delete') { + e.formData.delete('title'); + } + }); + + submit(ref.current); + + expect(button).toBe('delete'); + expect(title).toBe(null); + }); + + // @gate enableFormActions || !__DEV__ + it('allows a non-function formaction to override a function one', async () => { + const ref = React.createRef(); + let actionCalled = false; + + function action(formData) { + actionCalled = true; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( +
+ +
, + ); + }); + + let nav; + try { + submit(ref.current); + } catch (x) { + nav = x.message; + } + expect(nav).toBe('Navigate to: http://example.com/submit'); + expect(actionCalled).toBe(false); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js index 5bdfba529641b..ef232d76610cd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js @@ -108,12 +108,15 @@ describe('ReactDOMServerIntegration - Untrusted URLs', () => { expect(e.action).toBe('javascript:notfine'); }); - itRenders('a javascript protocol button formAction', async render => { - const e = await render(, 1); + itRenders('a javascript protocol input formAction', async render => { + const e = await render( + , + 1, + ); expect(e.getAttribute('formAction')).toBe('javascript:notfine'); }); - itRenders('a javascript protocol input formAction', async render => { + itRenders('a javascript protocol button formAction', async render => { const e = await render( , 1, @@ -268,12 +271,14 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', ( expect(e.action).toBe(EXPECTED_SAFE_URL); }); - itRenders('a javascript protocol button formAction', async render => { - const e = await render(); + itRenders('a javascript protocol input formAction', async render => { + const e = await render( + , + ); expect(e.getAttribute('formAction')).toBe(EXPECTED_SAFE_URL); }); - itRenders('a javascript protocol input formAction', async render => { + itRenders('a javascript protocol button formAction', async render => { const e = await render( , ); From c6b06b7ca15a082aa618794078656b4d7684c11e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Apr 2023 14:10:09 -0400 Subject: [PATCH 3/6] Update fixture Buttons with actions have to be wrapped in form elements. They can't be standalone unfortunately because it's the act of submitting the form that triggers the action. This also ensures that the progressive enhancement can work properly too. --- fixtures/flight/src/Button.js | 32 +++++++++++++++++--------------- fixtures/flight/src/Form.js | 4 +--- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/fixtures/flight/src/Button.js b/fixtures/flight/src/Button.js index bbdaeba2bde9c..78adf641139a8 100644 --- a/fixtures/flight/src/Button.js +++ b/fixtures/flight/src/Button.js @@ -6,20 +6,22 @@ export default function Button({action, children}) { const [isPending, setIsPending] = React.useState(false); return ( - +
+ +
); } diff --git a/fixtures/flight/src/Form.js b/fixtures/flight/src/Form.js index 3e8d8c244c08f..8b1bb01ad0e0d 100644 --- a/fixtures/flight/src/Form.js +++ b/fixtures/flight/src/Form.js @@ -7,11 +7,9 @@ export default function Form({action, children}) { return (
{ - e.preventDefault(); + action={async formData => { setIsPending(true); try { - const formData = new FormData(e.target); const result = await action(formData); alert(result); } catch (error) { From 1d738926a8edc7f58ca68ecb88a9465b36369282 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Apr 2023 16:00:29 -0400 Subject: [PATCH 4/6] Fix form field warnings --- packages/react-dom-bindings/src/client/ReactDOMComponent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index fcbaf116bb07e..dd3fad092c270 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -135,7 +135,7 @@ function validateFormActionInDevelopment( ); } else if (typeof value === 'function') { if ( - (props.formEncType != null || props.formMethod != null) && + (props.encType != null || props.method != null) && !didWarnFormActionMethod ) { didWarnFormActionMethod = true; @@ -145,7 +145,7 @@ function validateFormActionInDevelopment( 'They will get overridden.', ); } - if (props.formTarget != null && !didWarnFormActionTarget) { + if (props.target != null && !didWarnFormActionTarget) { didWarnFormActionTarget = true; console.error( 'Cannot specify a target for a form that specifies a function as the action. ' + From 6be6585209b30de8b8c8c0f9e5aaa0b958591adf Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Apr 2023 16:04:11 -0400 Subject: [PATCH 5/6] Grammar nits --- .../react-dom-bindings/src/client/ReactDOMComponent.js | 8 ++++---- .../react-dom-bindings/src/server/ReactFizzConfigDOM.js | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index dd3fad092c270..67cce3508ef8c 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -149,7 +149,7 @@ function validateFormActionInDevelopment( didWarnFormActionTarget = true; console.error( 'Cannot specify a target for a form that specifies a function as the action. ' + - 'The function will always be executed in the same scope.', + 'The function will always be executed in the same window.', ); } } @@ -183,8 +183,8 @@ function validateFormActionInDevelopment( if (props.name != null && !didWarnFormActionName) { didWarnFormActionName = true; console.error( - 'Cannot specify a "name" prop for a button that specifies a function as a formAction ' + - 'because React needs it to encode which action to be invoke. It will get overridden.', + 'Cannot specify a "name" prop for a button that specifies a function as a formAction. ' + + 'React needs it to encode which action should be invoked. It will get overridden.', ); } if ( @@ -201,7 +201,7 @@ function validateFormActionInDevelopment( didWarnFormActionTarget = true; console.error( 'Cannot specify a formTarget for a button that specifies a function as a formAction. ' + - 'The function will always be executed in the same scope.', + 'The function will always be executed in the same window.', ); } } diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index e859f8a10138a..25d296dbd0d60 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -659,8 +659,8 @@ function pushFormActionAttribute( if (name !== null && !didWarnFormActionName) { didWarnFormActionName = true; console.error( - 'Cannot specify a "name" prop for a button that specifies a function as a formAction ' + - 'because React needs it to encode which action to be invoke. It will get overridden.', + 'Cannot specify a "name" prop for a button that specifies a function as a formAction. ' + + 'React needs it to encode which action should be invoked. It will get overridden.', ); } if ( @@ -677,7 +677,7 @@ function pushFormActionAttribute( didWarnFormActionTarget = true; console.error( 'Cannot specify a formTarget for a button that specifies a function as a formAction. ' + - 'The function will always be executed in the same scope.', + 'The function will always be executed in the same window.', ); } } @@ -1329,7 +1329,7 @@ function pushStartForm( didWarnFormActionTarget = true; console.error( 'Cannot specify a target for a form that specifies a function as the action. ' + - 'The function will always be executed in the same scope.', + 'The function will always be executed in the same window.', ); } } From 5a7629ddb4f22072e37ba1ae581977ce03bdf288 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Apr 2023 16:19:24 -0400 Subject: [PATCH 6/6] Handle the case where the submitter target is not a React element This can still possibly have a null formAction in which case React still handles it. --- .../events/plugins/FormActionEventPlugin.js | 5 ++- .../src/__tests__/ReactDOMForm-test.js | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js index 2a6c1c19600ff..2e16ea4f4f2e3 100644 --- a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js @@ -44,7 +44,10 @@ function extractEvents( (nativeEvent: any).submitter; let submitterAction; if (submitter) { - submitterAction = (getFiberCurrentPropsFromNode(submitter): any).formAction; + const submitterProps = getFiberCurrentPropsFromNode(submitter); + submitterAction = submitterProps + ? (submitterProps: any).formAction + : submitter.getAttribute('formAction'); if (submitterAction != null) { // The submitter overrides the form action. action = submitterAction; diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index cc14655d3160b..d52ab9087d943 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -414,4 +414,40 @@ describe('ReactDOMForm', () => { expect(nav).toBe('Navigate to: http://example.com/submit'); expect(actionCalled).toBe(false); }); + + // @gate enableFormActions || !__DEV__ + it('allows a non-react html formaction to be invoked', async () => { + let actionCalled = false; + + function action(formData) { + actionCalled = true; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( + + `, + }} + />, + ); + }); + + const node = container.getElementsByTagName('input')[0]; + let nav; + try { + submit(node); + } catch (x) { + nav = x.message; + } + expect(nav).toBe('Navigate to: http://example.com/submit'); + expect(actionCalled).toBe(false); + }); });