From c826dc50de288758a0b783b2fd37b40a3b512fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 19 Apr 2023 16:31:08 -0400 Subject: [PATCH] Add (Client) Functions as Form Actions (#26674) This lets you pass a function 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 0000000000..d52ab9087d --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -0,0 +1,453 @@ +/** + * 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); + }); + + // @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); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js index 5bdfba5296..ef232d7661 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( , ); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 5bb3bd1128..83350f83ee 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 0e2c915731..370207d9d0 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 ef559e28b5..1b9aa46310 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 221663c3a8..c2e8fd14f5 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 d4a0e763f7..ce6fe31f10 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 bff726e27c..4fa45a9142 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 25fb6d818c..e578b08dd8 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -73,6 +73,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.