@samitouri / QOS-React / commits / c826dc50de

Add (Client) Functions as Form Actions (#26674)

This lets you pass a function to `<form action={...}>` or `<button formAction={...}>` or `<input type="submit formAction={...}>`. This will behave basically like a `javascript:` URL except not quite implemented that way. This is a convenience for the `onSubmit={e => { e.preventDefault(); const fromData = new FormData(e.target); ... }` pattern. You can still implement a custom `onSubmit` handler and if it calls `preventDefault`, it won't invoke the action, just like it would if you used a full page form navigation or javascript urls. It behaves just like a navigation and we might implement it with the Navigation API in the future. Currently this is just a synchronous function but in a follow up this will accept async functions, handle pending states and handle errors. This is implemented by setting `javascript:` URLs, but these only exist to trigger an error message if something goes wrong instead of navigating away. Like if you called `stopPropagation` to prevent React from handling it or if you called `form.submit()` instead of `form.requestSubmit()` which by-passes the `submit` event. If CSP is used to ban `javascript:` urls, those will trigger errors when these URLs are invoked which would be a different error message but it's still there to notify the user that something went wrong in the plumbing. Next up is improving the SSR state with action replaying and progressive enhancement.

Sebastian Markbåge committed Apr 19, 2023 at 16:31 UTC c826dc50de288758a0b783b2fd37b40a3b512fc4
18 files changed +1363 -82
fixtures/flight/src/Button.js
+17 -15
@@ -6,20 +6,22 @@ export default function Button({action, children}) {
6 const [isPending, setIsPending] = React.useState(false);
7
8 return (
9 - <button
10 - disabled={isPending}
11 - onClick={async () => {
12 - setIsPending(true);
13 - try {
14 - const result = await action();
15 - console.log(result);
16 - } catch (error) {
17 - console.error(error);
18 - } finally {
19 - setIsPending(false);
20 - }
21 - }}>
22 - {children}
23 - </button>
9 + <form>
10 + <button
11 + disabled={isPending}
12 + formAction={async () => {
13 + setIsPending(true);
14 + try {
15 + const result = await action();
16 + console.log(result);
17 + } catch (error) {
18 + console.error(error);
19 + } finally {
20 + setIsPending(false);
21 + }
22 + }}>
23 + {children}
24 + </button>
25 + </form>
26 );
27 }
fixtures/flight/src/Form.js
+1 -3
@@ -7,11 +7,9 @@ export default function Form({action, children}) {
7
8 return (
9 <form
10 - onSubmit={async e => {
11 - e.preventDefault();
10 + action={async formData => {
11 setIsPending(true);
12 try {
14 - const formData = new FormData(e.target);
13 const result = await action(formData);
14 alert(result);
15 } catch (error) {
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+172 -6
@@ -65,6 +65,7 @@ import sanitizeURL from '../shared/sanitizeURL';
65 import {
66 enableCustomElementPropertySupport,
67 enableClientRenderFallbackOnTextMismatch,
68 + enableFormActions,
69 enableHostSingletons,
70 disableIEWorkarounds,
71 enableTrustedTypesIntegration,
@@ -79,6 +80,10 @@ import {
80 let didWarnControlledToUncontrolled = false;
81 let didWarnUncontrolledToControlled = false;
82 let didWarnInvalidHydration = false;
83 +let didWarnFormActionType = false;
84 +let didWarnFormActionName = false;
85 +let didWarnFormActionTarget = false;
86 +let didWarnFormActionMethod = false;
87 let canDiffStyleForHydrationWarning;
88 if (__DEV__) {
89 // IE 11 parses & normalizes the style attribute as opposed to other
@@ -116,6 +121,102 @@ function validatePropertiesInDevelopment(type: string, props: any) {
121 }
122 }
123
124 +function validateFormActionInDevelopment(
125 + tag: string,
126 + key: string,
127 + value: mixed,
128 + props: any,
129 +) {
130 + if (__DEV__) {
131 + if (tag === 'form') {
132 + if (key === 'formAction') {
133 + console.error(
134 + 'You can only pass the formAction prop to <input> or <button>. Use the action prop on <form>.',
135 + );
136 + } else if (typeof value === 'function') {
137 + if (
138 + (props.encType != null || props.method != null) &&
139 + !didWarnFormActionMethod
140 + ) {
141 + didWarnFormActionMethod = true;
142 + console.error(
143 + 'Cannot specify a encType or method for a form that specifies a ' +
144 + 'function as the action. React provides those automatically. ' +
145 + 'They will get overridden.',
146 + );
147 + }
148 + if (props.target != null && !didWarnFormActionTarget) {
149 + didWarnFormActionTarget = true;
150 + console.error(
151 + 'Cannot specify a target for a form that specifies a function as the action. ' +
152 + 'The function will always be executed in the same window.',
153 + );
154 + }
155 + }
156 + } else if (tag === 'input' || tag === 'button') {
157 + if (key === 'action') {
158 + console.error(
159 + 'You can only pass the action prop to <form>. Use the formAction prop on <input> or <button>.',
160 + );
161 + } else if (
162 + tag === 'input' &&
163 + props.type !== 'submit' &&
164 + props.type !== 'image' &&
165 + !didWarnFormActionType
166 + ) {
167 + didWarnFormActionType = true;
168 + console.error(
169 + 'An input can only specify a formAction along with type="submit" or type="image".',
170 + );
171 + } else if (
172 + tag === 'button' &&
173 + props.type != null &&
174 + props.type !== 'submit' &&
175 + !didWarnFormActionType
176 + ) {
177 + didWarnFormActionType = true;
178 + console.error(
179 + 'A button can only specify a formAction along with type="submit" or no type.',
180 + );
181 + } else if (typeof value === 'function') {
182 + // Function form actions cannot control the form properties
183 + if (props.name != null && !didWarnFormActionName) {
184 + didWarnFormActionName = true;
185 + console.error(
186 + 'Cannot specify a "name" prop for a button that specifies a function as a formAction. ' +
187 + 'React needs it to encode which action should be invoked. It will get overridden.',
188 + );
189 + }
190 + if (
191 + (props.formEncType != null || props.formMethod != null) &&
192 + !didWarnFormActionMethod
193 + ) {
194 + didWarnFormActionMethod = true;
195 + console.error(
196 + 'Cannot specify a formEncType or formMethod for a button that specifies a ' +
197 + 'function as a formAction. React provides those automatically. They will get overridden.',
198 + );
199 + }
200 + if (props.formTarget != null && !didWarnFormActionTarget) {
201 + didWarnFormActionTarget = true;
202 + console.error(
203 + 'Cannot specify a formTarget for a button that specifies a function as a formAction. ' +
204 + 'The function will always be executed in the same window.',
205 + );
206 + }
207 + }
208 + } else {
209 + if (key === 'action') {
210 + console.error('You can only pass the action prop to <form>.');
211 + } else {
212 + console.error(
213 + 'You can only pass the formAction prop to <input> or <button>.',
214 + );
215 + }
216 + }
217 + }
218 +}
219 +
220 function warnForPropDifference(
221 propName: string,
222 serverValue: mixed,
@@ -327,8 +428,7 @@ function setProp(
428 }
429 // These attributes accept URLs. These must not allow javascript: URLS.
430 case 'src':
330 - case 'href':
331 - case 'action':
431 + case 'href': {
432 if (enableFilterEmptyStringAttributesDOM) {
433 if (value === '') {
434 if (__DEV__) {
@@ -355,8 +455,6 @@ function setProp(
455 break;
456 }
457 }
358 - // Fall through to the last case which shouldn't remove empty strings.
359 - case 'formAction': {
458 if (
459 value == null ||
460 typeof value === 'function' ||
@@ -377,6 +475,50 @@ function setProp(
475 domElement.setAttribute(key, sanitizedValue);
476 break;
477 }
478 + case 'action':
479 + case 'formAction': {
480 + // TODO: Consider moving these special cases to the form, input and button tags.
481 + if (
482 + value == null ||
483 + (!enableFormActions && typeof value === 'function') ||
484 + typeof value === 'symbol' ||
485 + typeof value === 'boolean'
486 + ) {
487 + domElement.removeAttribute(key);
488 + break;
489 + }
490 + if (__DEV__) {
491 + validateFormActionInDevelopment(tag, key, value, props);
492 + }
493 + if (enableFormActions && typeof value === 'function') {
494 + // Set a javascript URL that doesn't do anything. We don't expect this to be invoked
495 + // because we'll preventDefault, but it can happen if a form is manually submitted or
496 + // if someone calls stopPropagation before React gets the event.
497 + // If CSP is used to block javascript: URLs that's fine too. It just won't show this
498 + // error message but the URL will be logged.
499 + domElement.setAttribute(
500 + key,
501 + // eslint-disable-next-line no-script-url
502 + "javascript:throw new Error('" +
503 + 'A React form was unexpectedly submitted. If you called form.submit() manually, ' +
504 + "consider using form.requestSubmit() instead. If you're trying to use " +
505 + 'event.stopPropagation() in a submit event handler, consider also calling ' +
506 + 'event.preventDefault().' +
507 + "')",
508 + );
509 + break;
510 + }
511 + // `setAttribute` with objects becomes only `[object]` in IE8/9,
512 + // ('' + value) makes it output the correct toString()-value.
513 + if (__DEV__) {
514 + checkAttributeStringCoercion(value, key);
515 + }
516 + const sanitizedValue = (sanitizeURL(
517 + enableTrustedTypesIntegration ? value : '' + (value: any),
518 + ): any);
519 + domElement.setAttribute(key, sanitizedValue);
520 + break;
521 + }
522 case 'onClick': {
523 // TODO: This cast may not be sound for SVG, MathML or custom elements.
524 if (value != null) {
@@ -2423,6 +2565,13 @@ function diffHydratedCustomComponent(
2565 }
2566 }
2567
2568 +// This is the exact URL string we expect that Fizz renders if we provide a function action.
2569 +// We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense
2570 +// as a shared module for that reason.
2571 +const EXPECTED_FORM_ACTION_URL =
2572 + // eslint-disable-next-line no-script-url
2573 + "javascript:throw new Error('A React form was unexpectedly submitted.')";
2574 +
2575 function diffHydratedGenericElement(
2576 domElement: Element,
2577 tag: string,
@@ -2505,7 +2654,6 @@ function diffHydratedGenericElement(
2654 }
2655 case 'src':
2656 case 'href':
2508 - case 'action':
2657 if (enableFilterEmptyStringAttributesDOM) {
2658 if (value === '') {
2659 if (__DEV__) {
@@ -2546,11 +2694,29 @@ function diffHydratedGenericElement(
2694 extraAttributes,
2695 );
2696 continue;
2697 + case 'action':
2698 case 'formAction':
2699 + if (enableFormActions) {
2700 + const serverValue = domElement.getAttribute(propKey);
2701 + const hasFormActionURL = serverValue === EXPECTED_FORM_ACTION_URL;
2702 + if (typeof value === 'function') {
2703 + extraAttributes.delete(propKey.toLowerCase());
2704 + if (hasFormActionURL) {
2705 + // Expected
2706 + continue;
2707 + }
2708 + warnForPropDifference(propKey, serverValue, value);
2709 + continue;
2710 + } else if (hasFormActionURL) {
2711 + extraAttributes.delete(propKey.toLowerCase());
2712 + warnForPropDifference(propKey, 'function', value);
2713 + continue;
2714 + }
2715 + }
2716 hydrateSanitizedAttribute(
2717 domElement,
2718 propKey,
2553 - 'formaction',
2719 + propKey.toLowerCase(),
2720 value,
2721 extraAttributes,
2722 );
packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
+13
@@ -54,6 +54,7 @@ import {
54 enableScopeAPI,
55 enableFloat,
56 enableHostSingletons,
57 + enableFormActions,
58 } from 'shared/ReactFeatureFlags';
59 import {
60 invokeGuardedCallbackAndCatchFirstError,
@@ -72,6 +73,7 @@ import * as ChangeEventPlugin from './plugins/ChangeEventPlugin';
73 import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin';
74 import * as SelectEventPlugin from './plugins/SelectEventPlugin';
75 import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
76 +import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
77
78 type DispatchListener = {
79 instance: null | Fiber,
@@ -173,6 +175,17 @@ function extractEvents(
175 eventSystemFlags,
176 targetContainer,
177 );
178 + if (enableFormActions) {
179 + FormActionEventPlugin.extractEvents(
180 + dispatchQueue,
181 + domEventName,
182 + targetInst,
183 + nativeEvent,
184 + nativeEventTarget,
185 + eventSystemFlags,
186 + targetContainer,
187 + );
188 + }
189 }
190 }
191
packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js new
+113
@@ -0,0 +1,113 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {AnyNativeEvent} from '../PluginModuleType';
11 +import type {DOMEventName} from '../DOMEventNames';
12 +import type {DispatchQueue} from '../DOMPluginEventSystem';
13 +import type {EventSystemFlags} from '../EventSystemFlags';
14 +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
15 +
16 +import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
17 +
18 +import {SyntheticEvent} from '../SyntheticEvent';
19 +
20 +/**
21 + * This plugin invokes action functions on forms, inputs and buttons if
22 + * the form doesn't prevent default.
23 + */
24 +function extractEvents(
25 + dispatchQueue: DispatchQueue,
26 + domEventName: DOMEventName,
27 + targetInst: null | Fiber,
28 + nativeEvent: AnyNativeEvent,
29 + nativeEventTarget: null | EventTarget,
30 + eventSystemFlags: EventSystemFlags,
31 + targetContainer: EventTarget,
32 +) {
33 + if (domEventName !== 'submit') {
34 + return;
35 + }
36 + if (!targetInst || targetInst.stateNode !== nativeEventTarget) {
37 + // If we're inside a parent root that itself is a parent of this root, then
38 + // its deepest target won't be the actual form that's being submitted.
39 + return;
40 + }
41 + const form: HTMLFormElement = (nativeEventTarget: any);
42 + let action = (getFiberCurrentPropsFromNode(form): any).action;
43 + const submitter: null | HTMLInputElement | HTMLButtonElement =
44 + (nativeEvent: any).submitter;
45 + let submitterAction;
46 + if (submitter) {
47 + const submitterProps = getFiberCurrentPropsFromNode(submitter);
48 + submitterAction = submitterProps
49 + ? (submitterProps: any).formAction
50 + : submitter.getAttribute('formAction');
51 + if (submitterAction != null) {
52 + // The submitter overrides the form action.
53 + action = submitterAction;
54 + }
55 + }
56 +
57 + if (typeof action !== 'function') {
58 + return;
59 + }
60 +
61 + const event = new SyntheticEvent(
62 + 'action',
63 + 'action',
64 + null,
65 + nativeEvent,
66 + nativeEventTarget,
67 + );
68 +
69 + function submitForm() {
70 + if (nativeEvent.defaultPrevented) {
71 + // We let earlier events to prevent the action from submitting.
72 + return;
73 + }
74 + // Prevent native navigation.
75 + event.preventDefault();
76 + let formData;
77 + if (submitter) {
78 + // The submitter's value should be included in the FormData.
79 + // It should be in the document order in the form.
80 + // Since the FormData constructor invokes the formdata event it also
81 + // needs to be available before that happens so after construction it's too
82 + // late. The easiest way to do this is to switch the form field to hidden,
83 + // which is always included, and then back again. This does means that this
84 + // is observable from the formdata event though.
85 + // TODO: This tricky doesn't work on button elements. Consider inserting
86 + // a fake node instead for that case.
87 + // TODO: FormData takes a second argument that it's the submitter but this
88 + // is fairly new so not all browsers support it yet. Switch to that technique
89 + // when available.
90 + const type = submitter.type;
91 + submitter.type = 'hidden';
92 + formData = new FormData(form);
93 + submitter.type = type;
94 + } else {
95 + formData = new FormData(form);
96 + }
97 + // TODO: Deal with errors and pending state.
98 + action(formData);
99 + }
100 +
101 + dispatchQueue.push({
102 + event,
103 + listeners: [
104 + {
105 + instance: null,
106 + listener: submitForm,
107 + currentTarget: form,
108 + },
109 + ],
110 + });
111 +}
112 +
113 +export {extractEvents};
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+352 -38
@@ -21,6 +21,7 @@ import {
21 enableFilterEmptyStringAttributesDOM,
22 enableCustomElementPropertySupport,
23 enableFloat,
24 + enableFormActions,
25 enableFizzExternalRuntime,
26 } from 'shared/ReactFeatureFlags';
27
@@ -635,6 +636,83 @@ function pushStringAttribute(
636 }
637 }
638
639 +// Since this will likely be repeated a lot in the HTML, we use a more concise message
640 +// than on the client and hopefully it's googleable.
641 +const actionJavaScriptURL = stringToPrecomputedChunk(
642 + escapeTextForBrowser(
643 + // eslint-disable-next-line no-script-url
644 + "javascript:throw new Error('A React form was unexpectedly submitted.')",
645 + ),
646 +);
647 +
648 +function pushFormActionAttribute(
649 + target: Array<Chunk | PrecomputedChunk>,
650 + formAction: any,
651 + formEncType: any,
652 + formMethod: any,
653 + formTarget: any,
654 + name: any,
655 +): void {
656 + if (enableFormActions && typeof formAction === 'function') {
657 + // Function form actions cannot control the form properties
658 + if (__DEV__) {
659 + if (name !== null && !didWarnFormActionName) {
660 + didWarnFormActionName = true;
661 + console.error(
662 + 'Cannot specify a "name" prop for a button that specifies a function as a formAction. ' +
663 + 'React needs it to encode which action should be invoked. It will get overridden.',
664 + );
665 + }
666 + if (
667 + (formEncType !== null || formMethod !== null) &&
668 + !didWarnFormActionMethod
669 + ) {
670 + didWarnFormActionMethod = true;
671 + console.error(
672 + 'Cannot specify a formEncType or formMethod for a button that specifies a ' +
673 + 'function as a formAction. React provides those automatically. They will get overridden.',
674 + );
675 + }
676 + if (formTarget !== null && !didWarnFormActionTarget) {
677 + didWarnFormActionTarget = true;
678 + console.error(
679 + 'Cannot specify a formTarget for a button that specifies a function as a formAction. ' +
680 + 'The function will always be executed in the same window.',
681 + );
682 + }
683 + }
684 + // Set a javascript URL that doesn't do anything. We don't expect this to be invoked
685 + // because we'll preventDefault in the Fizz runtime, but it can happen if a form is
686 + // manually submitted or if someone calls stopPropagation before React gets the event.
687 + // If CSP is used to block javascript: URLs that's fine too. It just won't show this
688 + // error message but the URL will be logged.
689 + target.push(
690 + attributeSeparator,
691 + stringToChunk('formAction'),
692 + attributeAssign,
693 + actionJavaScriptURL,
694 + attributeEnd,
695 + );
696 + } else {
697 + // Plain form actions support all the properties, so we have to emit them.
698 + if (name !== null) {
699 + pushAttribute(target, 'name', name);
700 + }
701 + if (formAction !== null) {
702 + pushAttribute(target, 'formAction', formAction);
703 + }
704 + if (formEncType !== null) {
705 + pushAttribute(target, 'formEncType', formEncType);
706 + }
707 + if (formMethod !== null) {
708 + pushAttribute(target, 'formMethod', formMethod);
709 + }
710 + if (formTarget !== null) {
711 + pushAttribute(target, 'formTarget', formTarget);
712 + }
713 + }
714 +}
715 +
716 function pushAttribute(
717 target: Array<Chunk | PrecomputedChunk>,
718 name: string,
@@ -665,8 +743,7 @@ function pushAttribute(
743 return;
744 }
745 case 'src':
668 - case 'href':
669 - case 'action':
746 + case 'href': {
747 if (enableFilterEmptyStringAttributesDOM) {
748 if (value === '') {
749 if (__DEV__) {
@@ -692,8 +769,11 @@ function pushAttribute(
769 return;
770 }
771 }
772 + }
773 // Fall through to the last case which shouldn't remove empty strings.
774 + case 'action':
775 case 'formAction': {
776 + // TODO: Consider only special casing these for each tag.
777 if (
778 value == null ||
779 typeof value === 'function' ||
@@ -970,6 +1050,10 @@ let didWarnDefaultTextareaValue = false;
1050 let didWarnInvalidOptionChildren = false;
1051 let didWarnInvalidOptionInnerHTML = false;
1052 let didWarnSelectedSetOnOption = false;
1053 +let didWarnFormActionType = false;
1054 +let didWarnFormActionName = false;
1055 +let didWarnFormActionTarget = false;
1056 +let didWarnFormActionMethod = false;
1057
1058 function checkSelectProp(props: any, propName: string) {
1059 if (__DEV__) {
@@ -1182,51 +1266,127 @@ function pushStartOption(
1266 return children;
1267 }
1268
1185 -function pushInput(
1269 +function pushStartForm(
1270 target: Array<Chunk | PrecomputedChunk>,
1271 props: Object,
1272 ): ReactNodeList {
1189 - if (__DEV__) {
1190 - checkControlledValueProps('input', props);
1273 + target.push(startChunkForTag('form'));
1274
1192 - if (
1193 - props.checked !== undefined &&
1194 - props.defaultChecked !== undefined &&
1195 - !didWarnDefaultChecked
1196 - ) {
1197 - console.error(
1198 - '%s contains an input of type %s with both checked and defaultChecked props. ' +
1199 - 'Input elements must be either controlled or uncontrolled ' +
1200 - '(specify either the checked prop, or the defaultChecked prop, but not ' +
1201 - 'both). Decide between using a controlled or uncontrolled input ' +
1202 - 'element and remove one of these props. More info: ' +
1203 - 'https://reactjs.org/link/controlled-components',
1204 - 'A component',
1205 - props.type,
1206 - );
1207 - didWarnDefaultChecked = true;
1275 + let children = null;
1276 + let innerHTML = null;
1277 + let formAction = null;
1278 + let formEncType = null;
1279 + let formMethod = null;
1280 + let formTarget = null;
1281 +
1282 + for (const propKey in props) {
1283 + if (hasOwnProperty.call(props, propKey)) {
1284 + const propValue = props[propKey];
1285 + if (propValue == null) {
1286 + continue;
1287 + }
1288 + switch (propKey) {
1289 + case 'children':
1290 + children = propValue;
1291 + break;
1292 + case 'dangerouslySetInnerHTML':
1293 + innerHTML = propValue;
1294 + break;
1295 + case 'action':
1296 + formAction = propValue;
1297 + break;
1298 + case 'encType':
1299 + formEncType = propValue;
1300 + break;
1301 + case 'method':
1302 + formMethod = propValue;
1303 + break;
1304 + case 'target':
1305 + formTarget = propValue;
1306 + break;
1307 + default:
1308 + pushAttribute(target, propKey, propValue);
1309 + break;
1310 + }
1311 }
1209 - if (
1210 - props.value !== undefined &&
1211 - props.defaultValue !== undefined &&
1212 - !didWarnDefaultInputValue
1213 - ) {
1214 - console.error(
1215 - '%s contains an input of type %s with both value and defaultValue props. ' +
1216 - 'Input elements must be either controlled or uncontrolled ' +
1217 - '(specify either the value prop, or the defaultValue prop, but not ' +
1218 - 'both). Decide between using a controlled or uncontrolled input ' +
1219 - 'element and remove one of these props. More info: ' +
1220 - 'https://reactjs.org/link/controlled-components',
1221 - 'A component',
1222 - props.type,
1223 - );
1224 - didWarnDefaultInputValue = true;
1312 + }
1313 +
1314 + if (enableFormActions && typeof formAction === 'function') {
1315 + // Function form actions cannot control the form properties
1316 + if (__DEV__) {
1317 + if (
1318 + (formEncType !== null || formMethod !== null) &&
1319 + !didWarnFormActionMethod
1320 + ) {
1321 + didWarnFormActionMethod = true;
1322 + console.error(
1323 + 'Cannot specify a encType or method for a form that specifies a ' +
1324 + 'function as the action. React provides those automatically. ' +
1325 + 'They will get overridden.',
1326 + );
1327 + }
1328 + if (formTarget !== null && !didWarnFormActionTarget) {
1329 + didWarnFormActionTarget = true;
1330 + console.error(
1331 + 'Cannot specify a target for a form that specifies a function as the action. ' +
1332 + 'The function will always be executed in the same window.',
1333 + );
1334 + }
1335 + }
1336 + // Set a javascript URL that doesn't do anything. We don't expect this to be invoked
1337 + // because we'll preventDefault in the Fizz runtime, but it can happen if a form is
1338 + // manually submitted or if someone calls stopPropagation before React gets the event.
1339 + // If CSP is used to block javascript: URLs that's fine too. It just won't show this
1340 + // error message but the URL will be logged.
1341 + target.push(
1342 + attributeSeparator,
1343 + stringToChunk('action'),
1344 + attributeAssign,
1345 + actionJavaScriptURL,
1346 + attributeEnd,
1347 + );
1348 + } else {
1349 + // Plain form actions support all the properties, so we have to emit them.
1350 + if (formAction !== null) {
1351 + pushAttribute(target, 'action', formAction);
1352 + }
1353 + if (formEncType !== null) {
1354 + pushAttribute(target, 'encType', formEncType);
1355 + }
1356 + if (formMethod !== null) {
1357 + pushAttribute(target, 'method', formMethod);
1358 }
1359 + if (formTarget !== null) {
1360 + pushAttribute(target, 'target', formTarget);
1361 + }
1362 + }
1363 +
1364 + target.push(endOfStartTag);
1365 + pushInnerHTML(target, innerHTML, children);
1366 + if (typeof children === 'string') {
1367 + // Special case children as a string to avoid the unnecessary comment.
1368 + // TODO: Remove this special case after the general optimization is in place.
1369 + target.push(stringToChunk(encodeHTMLTextNode(children)));
1370 + return null;
1371 + }
1372 + return children;
1373 +}
1374 +
1375 +function pushInput(
1376 + target: Array<Chunk | PrecomputedChunk>,
1377 + props: Object,
1378 +): ReactNodeList {
1379 + if (__DEV__) {
1380 + checkControlledValueProps('input', props);
1381 }
1382
1383 target.push(startChunkForTag('input'));
1384
1385 + let name = null;
1386 + let formAction = null;
1387 + let formEncType = null;
1388 + let formMethod = null;
1389 + let formTarget = null;
1390 let value = null;
1391 let defaultValue = null;
1392 let checked = null;
@@ -1245,6 +1405,21 @@ function pushInput(
1405 `${'input'} is a self-closing tag and must neither have \`children\` nor ` +
1406 'use `dangerouslySetInnerHTML`.',
1407 );
1408 + case 'name':
1409 + name = propValue;
1410 + break;
1411 + case 'formAction':
1412 + formAction = propValue;
1413 + break;
1414 + case 'formEncType':
1415 + formEncType = propValue;
1416 + break;
1417 + case 'formMethod':
1418 + formMethod = propValue;
1419 + break;
1420 + case 'formTarget':
1421 + formTarget = propValue;
1422 + break;
1423 case 'defaultChecked':
1424 defaultChecked = propValue;
1425 break;
@@ -1264,6 +1439,58 @@ function pushInput(
1439 }
1440 }
1441
1442 + if (__DEV__) {
1443 + if (
1444 + formAction !== null &&
1445 + props.type !== 'image' &&
1446 + props.type !== 'submit' &&
1447 + !didWarnFormActionType
1448 + ) {
1449 + didWarnFormActionType = true;
1450 + console.error(
1451 + 'An input can only specify a formAction along with type="submit" or type="image".',
1452 + );
1453 + }
1454 + }
1455 +
1456 + pushFormActionAttribute(
1457 + target,
1458 + formAction,
1459 + formEncType,
1460 + formMethod,
1461 + formTarget,
1462 + name,
1463 + );
1464 +
1465 + if (__DEV__) {
1466 + if (checked !== null && defaultChecked !== null && !didWarnDefaultChecked) {
1467 + console.error(
1468 + '%s contains an input of type %s with both checked and defaultChecked props. ' +
1469 + 'Input elements must be either controlled or uncontrolled ' +
1470 + '(specify either the checked prop, or the defaultChecked prop, but not ' +
1471 + 'both). Decide between using a controlled or uncontrolled input ' +
1472 + 'element and remove one of these props. More info: ' +
1473 + 'https://reactjs.org/link/controlled-components',
1474 + 'A component',
1475 + props.type,
1476 + );
1477 + didWarnDefaultChecked = true;
1478 + }
1479 + if (value !== null && defaultValue !== null && !didWarnDefaultInputValue) {
1480 + console.error(
1481 + '%s contains an input of type %s with both value and defaultValue props. ' +
1482 + 'Input elements must be either controlled or uncontrolled ' +
1483 + '(specify either the value prop, or the defaultValue prop, but not ' +
1484 + 'both). Decide between using a controlled or uncontrolled input ' +
1485 + 'element and remove one of these props. More info: ' +
1486 + 'https://reactjs.org/link/controlled-components',
1487 + 'A component',
1488 + props.type,
1489 + );
1490 + didWarnDefaultInputValue = true;
1491 + }
1492 + }
1493 +
1494 if (checked !== null) {
1495 pushBooleanAttribute(target, 'checked', checked);
1496 } else if (defaultChecked !== null) {
@@ -1279,6 +1506,89 @@ function pushInput(
1506 return null;
1507 }
1508
1509 +function pushStartButton(
1510 + target: Array<Chunk | PrecomputedChunk>,
1511 + props: Object,
1512 +): ReactNodeList {
1513 + target.push(startChunkForTag('button'));
1514 +
1515 + let children = null;
1516 + let innerHTML = null;
1517 + let name = null;
1518 + let formAction = null;
1519 + let formEncType = null;
1520 + let formMethod = null;
1521 + let formTarget = null;
1522 +
1523 + for (const propKey in props) {
1524 + if (hasOwnProperty.call(props, propKey)) {
1525 + const propValue = props[propKey];
1526 + if (propValue == null) {
1527 + continue;
1528 + }
1529 + switch (propKey) {
1530 + case 'children':
1531 + children = propValue;
1532 + break;
1533 + case 'dangerouslySetInnerHTML':
1534 + innerHTML = propValue;
1535 + break;
1536 + case 'name':
1537 + name = propValue;
1538 + break;
1539 + case 'formAction':
1540 + formAction = propValue;
1541 + break;
1542 + case 'formEncType':
1543 + formEncType = propValue;
1544 + break;
1545 + case 'formMethod':
1546 + formMethod = propValue;
1547 + break;
1548 + case 'formTarget':
1549 + formTarget = propValue;
1550 + break;
1551 + default:
1552 + pushAttribute(target, propKey, propValue);
1553 + break;
1554 + }
1555 + }
1556 + }
1557 +
1558 + if (__DEV__) {
1559 + if (
1560 + formAction !== null &&
1561 + props.type != null &&
1562 + props.type !== 'submit' &&
1563 + !didWarnFormActionType
1564 + ) {
1565 + didWarnFormActionType = true;
1566 + console.error(
1567 + 'A button can only specify a formAction along with type="submit" or no type.',
1568 + );
1569 + }
1570 + }
1571 +
1572 + pushFormActionAttribute(
1573 + target,
1574 + formAction,
1575 + formEncType,
1576 + formMethod,
1577 + formTarget,
1578 + name,
1579 + );
1580 +
1581 + target.push(endOfStartTag);
1582 + pushInnerHTML(target, innerHTML, children);
1583 + if (typeof children === 'string') {
1584 + // Special case children as a string to avoid the unnecessary comment.
1585 + // TODO: Remove this special case after the general optimization is in place.
1586 + target.push(stringToChunk(encodeHTMLTextNode(children)));
1587 + return null;
1588 + }
1589 + return children;
1590 +}
1591 +
1592 function pushStartTextArea(
1593 target: Array<Chunk | PrecomputedChunk>,
1594 props: Object,
@@ -2648,6 +2958,10 @@ export function pushStartInstance(
2958 return pushStartTextArea(target, props);
2959 case 'input':
2960 return pushInput(target, props);
2961 + case 'button':
2962 + return pushStartButton(target, props);
2963 + case 'form':
2964 + return pushStartForm(target, props);
2965 case 'menuitem':
2966 return pushStartMenuItem(target, props);
2967 case 'title':
@@ -2729,7 +3043,7 @@ export function pushStartInstance(
3043 case 'font-face-format':
3044 case 'font-face-name':
3045 case 'missing-glyph': {
2732 - return pushStartGenericElement(target, props, type);
3046 + break;
3047 }
3048 // Preamble start tags
3049 case 'head':
packages/react-dom-bindings/src/shared/ReactDOMUnknownPropertyHook.js
+19 -1
@@ -9,7 +9,10 @@ import {ATTRIBUTE_NAME_CHAR} from './isAttributeNameSafe';
9 import isCustomElement from './isCustomElement';
10 import possibleStandardNames from './possibleStandardNames';
11 import hasOwnProperty from 'shared/hasOwnProperty';
12 -import {enableCustomElementPropertySupport} from 'shared/ReactFeatureFlags';
12 +import {
13 + enableCustomElementPropertySupport,
14 + enableFormActions,
15 +} from 'shared/ReactFeatureFlags';
16
17 const warnedProperties = {};
18 const EVENT_NAME_REGEX = /^on./;
@@ -38,6 +41,21 @@ function validateProperty(tagName, name, value, eventRegistry) {
41 return true;
42 }
43
44 + if (enableFormActions) {
45 + // Actions are special because unlike events they can have other value types.
46 + if (typeof value === 'function') {
47 + if (tagName === 'form' && name === 'action') {
48 + return true;
49 + }
50 + if (tagName === 'input' && name === 'formAction') {
51 + return true;
52 + }
53 + if (tagName === 'button' && name === 'formAction') {
54 + return true;
55 + }
56 + }
57 + }
58 +
59 // We can't rely on the event system being injected on the server.
60 if (eventRegistry != null) {
61 const {registrationNameDependencies, possibleRegistrationNames} =
packages/react-dom/src/__tests__/ReactDOMComponent-test.js
+5 -13
@@ -512,25 +512,17 @@ describe('ReactDOMComponent', () => {
512 expect(node.hasAttribute('href')).toBe(false);
513 });
514
515 - it('should not add an empty action attribute', () => {
515 + it('should allow an empty action attribute', () => {
516 const container = document.createElement('div');
517 - expect(() => ReactDOM.render(<form action="" />, container)).toErrorDev(
518 - 'An empty string ("") was passed to the action attribute. ' +
519 - 'To fix this, either do not render the element at all ' +
520 - 'or pass null to action instead of an empty string.',
521 - );
517 + ReactDOM.render(<form action="" />, container);
518 const node = container.firstChild;
523 - expect(node.hasAttribute('action')).toBe(false);
519 + expect(node.getAttribute('action')).toBe('');
520
521 ReactDOM.render(<form action="abc" />, container);
522 expect(node.hasAttribute('action')).toBe(true);
523
528 - expect(() => ReactDOM.render(<form action="" />, container)).toErrorDev(
529 - 'An empty string ("") was passed to the action attribute. ' +
530 - 'To fix this, either do not render the element at all ' +
531 - 'or pass null to action instead of an empty string.',
532 - );
533 - expect(node.hasAttribute('action')).toBe(false);
524 + ReactDOM.render(<form action="" />, container);
525 + expect(node.getAttribute('action')).toBe('');
526 });
527
528 it('allows empty string of a formAction to override the default of a parent', () => {
packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js new
+198
@@ -0,0 +1,198 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +// Polyfills for test environment
13 +global.ReadableStream =
14 + require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15 +global.TextEncoder = require('util').TextEncoder;
16 +
17 +let act;
18 +let container;
19 +let React;
20 +let ReactDOMServer;
21 +let ReactDOMClient;
22 +
23 +describe('ReactDOMFizzForm', () => {
24 + beforeEach(() => {
25 + jest.resetModules();
26 + React = require('react');
27 + ReactDOMServer = require('react-dom/server.browser');
28 + ReactDOMClient = require('react-dom/client');
29 + act = require('internal-test-utils').act;
30 + container = document.createElement('div');
31 + document.body.appendChild(container);
32 + });
33 +
34 + afterEach(() => {
35 + document.body.removeChild(container);
36 + });
37 +
38 + function submit(submitter) {
39 + const form = submitter.form || submitter;
40 + if (!submitter.form) {
41 + submitter = undefined;
42 + }
43 + const submitEvent = new Event('submit', {bubbles: true, cancelable: true});
44 + submitEvent.submitter = submitter;
45 + const returnValue = form.dispatchEvent(submitEvent);
46 + if (!returnValue) {
47 + return;
48 + }
49 + const action =
50 + (submitter && submitter.getAttribute('formaction')) || form.action;
51 + if (!/\s*javascript:/i.test(action)) {
52 + throw new Error('Navigate to: ' + action);
53 + }
54 + }
55 +
56 + async function readIntoContainer(stream) {
57 + const reader = stream.getReader();
58 + let result = '';
59 + while (true) {
60 + const {done, value} = await reader.read();
61 + if (done) {
62 + break;
63 + }
64 + result += Buffer.from(value).toString('utf8');
65 + }
66 + container.innerHTML = result;
67 + }
68 +
69 + // @gate enableFormActions
70 + it('should allow passing a function to form action during SSR', async () => {
71 + const ref = React.createRef();
72 + let foo;
73 +
74 + function action(formData) {
75 + foo = formData.get('foo');
76 + }
77 + function App() {
78 + return (
79 + <form action={action} ref={ref}>
80 + <input type="text" name="foo" defaultValue="bar" />
81 + </form>
82 + );
83 + }
84 +
85 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
86 + await readIntoContainer(stream);
87 + await act(async () => {
88 + ReactDOMClient.hydrateRoot(container, <App />);
89 + });
90 +
91 + submit(ref.current);
92 +
93 + expect(foo).toBe('bar');
94 + });
95 +
96 + // @gate enableFormActions
97 + it('should allow passing a function to an input/button formAction', async () => {
98 + const inputRef = React.createRef();
99 + const buttonRef = React.createRef();
100 + let rootActionCalled = false;
101 + let savedTitle = null;
102 + let deletedTitle = null;
103 +
104 + function action(formData) {
105 + rootActionCalled = true;
106 + }
107 +
108 + function saveItem(formData) {
109 + savedTitle = formData.get('title');
110 + }
111 +
112 + function deleteItem(formData) {
113 + deletedTitle = formData.get('title');
114 + }
115 +
116 + function App() {
117 + return (
118 + <form action={action}>
119 + <input type="text" name="title" defaultValue="Hello" />
120 + <input
121 + type="submit"
122 + formAction={saveItem}
123 + value="Save"
124 + ref={inputRef}
125 + />
126 + <button formAction={deleteItem} ref={buttonRef}>
127 + Delete
128 + </button>
129 + </form>
130 + );
131 + }
132 +
133 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
134 + await readIntoContainer(stream);
135 + await act(async () => {
136 + ReactDOMClient.hydrateRoot(container, <App />);
137 + });
138 +
139 + expect(savedTitle).toBe(null);
140 + expect(deletedTitle).toBe(null);
141 +
142 + submit(inputRef.current);
143 + expect(savedTitle).toBe('Hello');
144 + expect(deletedTitle).toBe(null);
145 + savedTitle = null;
146 +
147 + submit(buttonRef.current);
148 + expect(savedTitle).toBe(null);
149 + expect(deletedTitle).toBe('Hello');
150 + deletedTitle = null;
151 +
152 + expect(rootActionCalled).toBe(false);
153 + });
154 +
155 + // @gate enableFormActions || !__DEV__
156 + it('should warn when passing a function action during SSR and string during hydration', async () => {
157 + function action(formData) {}
158 + function App({isClient}) {
159 + return (
160 + <form action={isClient ? 'action' : action}>
161 + <input type="text" name="foo" defaultValue="bar" />
162 + </form>
163 + );
164 + }
165 +
166 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
167 + await readIntoContainer(stream);
168 + await expect(async () => {
169 + await act(async () => {
170 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
171 + });
172 + }).toErrorDev(
173 + 'Prop `action` did not match. Server: "function" Client: "action"',
174 + );
175 + });
176 +
177 + // @gate enableFormActions || !__DEV__
178 + it('should warn when passing a string during SSR and function during hydration', async () => {
179 + function action(formData) {}
180 + function App({isClient}) {
181 + return (
182 + <form action={isClient ? action : 'action'}>
183 + <input type="text" name="foo" defaultValue="bar" />
184 + </form>
185 + );
186 + }
187 +
188 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
189 + await readIntoContainer(stream);
190 + await expect(async () => {
191 + await act(async () => {
192 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
193 + });
194 + }).toErrorDev(
195 + 'Prop `action` did not match. Server: "action" Client: "function action(formData) {}"',
196 + );
197 + });
198 +});
packages/react-dom/src/__tests__/ReactDOMForm-test.js new
+453
@@ -0,0 +1,453 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +global.IS_REACT_ACT_ENVIRONMENT = true;
13 +
14 +// Our current version of JSDOM doesn't implement the event dispatching
15 +// so we polyfill it.
16 +const NativeFormData = global.FormData;
17 +const FormDataPolyfill = function FormData(form) {
18 + const formData = new NativeFormData(form);
19 + const formDataEvent = new Event('formdata', {
20 + bubbles: true,
21 + cancelable: false,
22 + });
23 + formDataEvent.formData = formData;
24 + form.dispatchEvent(formDataEvent);
25 + return formData;
26 +};
27 +NativeFormData.prototype.constructor = FormDataPolyfill;
28 +global.FormData = FormDataPolyfill;
29 +
30 +describe('ReactDOMForm', () => {
31 + let act;
32 + let container;
33 + let React;
34 + let ReactDOM;
35 + let ReactDOMClient;
36 +
37 + beforeEach(() => {
38 + jest.resetModules();
39 + React = require('react');
40 + ReactDOM = require('react-dom');
41 + ReactDOMClient = require('react-dom/client');
42 + act = require('internal-test-utils').act;
43 + container = document.createElement('div');
44 + document.body.appendChild(container);
45 + });
46 +
47 + afterEach(() => {
48 + document.body.removeChild(container);
49 + });
50 +
51 + function submit(submitter) {
52 + const form = submitter.form || submitter;
53 + if (!submitter.form) {
54 + submitter = undefined;
55 + }
56 + const submitEvent = new Event('submit', {bubbles: true, cancelable: true});
57 + submitEvent.submitter = submitter;
58 + const returnValue = form.dispatchEvent(submitEvent);
59 + if (!returnValue) {
60 + return;
61 + }
62 + const action =
63 + (submitter && submitter.getAttribute('formaction')) || form.action;
64 + if (!/\s*javascript:/i.test(action)) {
65 + throw new Error('Navigate to: ' + action);
66 + }
67 + }
68 +
69 + // @gate enableFormActions
70 + it('should allow passing a function to form action', async () => {
71 + const ref = React.createRef();
72 + let foo;
73 +
74 + function action(formData) {
75 + foo = formData.get('foo');
76 + }
77 +
78 + const root = ReactDOMClient.createRoot(container);
79 + await act(async () => {
80 + root.render(
81 + <form action={action} ref={ref}>
82 + <input type="text" name="foo" defaultValue="bar" />
83 + </form>,
84 + );
85 + });
86 +
87 + submit(ref.current);
88 +
89 + expect(foo).toBe('bar');
90 +
91 + // Try updating the action
92 +
93 + function action2(formData) {
94 + foo = formData.get('foo') + '2';
95 + }
96 +
97 + await act(async () => {
98 + root.render(
99 + <form action={action2} ref={ref}>
100 + <input type="text" name="foo" defaultValue="bar" />
101 + </form>,
102 + );
103 + });
104 +
105 + submit(ref.current);
106 +
107 + expect(foo).toBe('bar2');
108 + });
109 +
110 + // @gate enableFormActions
111 + it('should allow passing a function to an input/button formAction', async () => {
112 + const inputRef = React.createRef();
113 + const buttonRef = React.createRef();
114 + let rootActionCalled = false;
115 + let savedTitle = null;
116 + let deletedTitle = null;
117 +
118 + function action(formData) {
119 + rootActionCalled = true;
120 + }
121 +
122 + function saveItem(formData) {
123 + savedTitle = formData.get('title');
124 + }
125 +
126 + function deleteItem(formData) {
127 + deletedTitle = formData.get('title');
128 + }
129 +
130 + const root = ReactDOMClient.createRoot(container);
131 + await act(async () => {
132 + root.render(
133 + <form action={action}>
134 + <input type="text" name="title" defaultValue="Hello" />
135 + <input
136 + type="submit"
137 + formAction={saveItem}
138 + value="Save"
139 + ref={inputRef}
140 + />
141 + <button formAction={deleteItem} ref={buttonRef}>
142 + Delete
143 + </button>
144 + </form>,
145 + );
146 + });
147 +
148 + expect(savedTitle).toBe(null);
149 + expect(deletedTitle).toBe(null);
150 +
151 + submit(inputRef.current);
152 + expect(savedTitle).toBe('Hello');
153 + expect(deletedTitle).toBe(null);
154 + savedTitle = null;
155 +
156 + submit(buttonRef.current);
157 + expect(savedTitle).toBe(null);
158 + expect(deletedTitle).toBe('Hello');
159 + deletedTitle = null;
160 +
161 + // Try updating the actions
162 +
163 + function saveItem2(formData) {
164 + savedTitle = formData.get('title') + '2';
165 + }
166 +
167 + function deleteItem2(formData) {
168 + deletedTitle = formData.get('title') + '2';
169 + }
170 +
171 + await act(async () => {
172 + root.render(
173 + <form action={action}>
174 + <input type="text" name="title" defaultValue="Hello" />
175 + <input
176 + type="submit"
177 + formAction={saveItem2}
178 + value="Save"
179 + ref={inputRef}
180 + />
181 + <button formAction={deleteItem2} ref={buttonRef}>
182 + Delete
183 + </button>
184 + </form>,
185 + );
186 + });
187 +
188 + expect(savedTitle).toBe(null);
189 + expect(deletedTitle).toBe(null);
190 +
191 + submit(inputRef.current);
192 + expect(savedTitle).toBe('Hello2');
193 + expect(deletedTitle).toBe(null);
194 + savedTitle = null;
195 +
196 + submit(buttonRef.current);
197 + expect(savedTitle).toBe(null);
198 + expect(deletedTitle).toBe('Hello2');
199 +
200 + expect(rootActionCalled).toBe(false);
201 + });
202 +
203 + // @gate enableFormActions || !__DEV__
204 + it('should allow preventing default to block the action', async () => {
205 + const ref = React.createRef();
206 + let actionCalled = false;
207 +
208 + function action(formData) {
209 + actionCalled = true;
210 + }
211 +
212 + const root = ReactDOMClient.createRoot(container);
213 + await act(async () => {
214 + root.render(
215 + <form action={action} ref={ref} onSubmit={e => e.preventDefault()}>
216 + <input type="text" name="foo" defaultValue="bar" />
217 + </form>,
218 + );
219 + });
220 +
221 + submit(ref.current);
222 +
223 + expect(actionCalled).toBe(false);
224 + });
225 +
226 + // @gate enableFormActions
227 + it('should only submit the inner of nested forms', async () => {
228 + const ref = React.createRef();
229 + let data;
230 +
231 + function outerAction(formData) {
232 + data = formData.get('data') + 'outer';
233 + }
234 + function innerAction(formData) {
235 + data = formData.get('data') + 'inner';
236 + }
237 +
238 + const root = ReactDOMClient.createRoot(container);
239 + await expect(async () => {
240 + await act(async () => {
241 + // This isn't valid HTML but just in case.
242 + root.render(
243 + <form action={outerAction}>
244 + <input type="text" name="data" defaultValue="outer" />
245 + <form action={innerAction} ref={ref}>
246 + <input type="text" name="data" defaultValue="inner" />
247 + </form>
248 + </form>,
249 + );
250 + });
251 + }).toErrorDev([
252 + 'Warning: validateDOMNesting(...): <form> cannot appear as a descendant of <form>.' +
253 + '\n in form (at **)' +
254 + '\n in form (at **)',
255 + ]);
256 +
257 + submit(ref.current);
258 +
259 + expect(data).toBe('innerinner');
260 + });
261 +
262 + // @gate enableFormActions
263 + it('should only submit once if one root is nested inside the other', async () => {
264 + const ref = React.createRef();
265 + let outerCalled = 0;
266 + let innerCalled = 0;
267 + let bubbledSubmit = false;
268 +
269 + function outerAction(formData) {
270 + outerCalled++;
271 + }
272 +
273 + function innerAction(formData) {
274 + innerCalled++;
275 + }
276 +
277 + const innerContainerRef = React.createRef();
278 + const outerRoot = ReactDOMClient.createRoot(container);
279 + await act(async () => {
280 + outerRoot.render(
281 + // Nesting forms isn't valid HTML but just in case.
282 + <div onSubmit={() => (bubbledSubmit = true)}>
283 + <form action={outerAction}>
284 + <div ref={innerContainerRef} />
285 + </form>
286 + </div>,
287 + );
288 + });
289 +
290 + const innerRoot = ReactDOMClient.createRoot(innerContainerRef.current);
291 + await act(async () => {
292 + innerRoot.render(
293 + <form action={innerAction} ref={ref}>
294 + <input type="text" name="data" defaultValue="inner" />
295 + </form>,
296 + );
297 + });
298 +
299 + submit(ref.current);
300 +
301 + expect(bubbledSubmit).toBe(true);
302 + expect(outerCalled).toBe(0);
303 + expect(innerCalled).toBe(1);
304 + });
305 +
306 + // @gate enableFormActions
307 + it('should only submit once if a portal is nested inside its own root', async () => {
308 + const ref = React.createRef();
309 + let outerCalled = 0;
310 + let innerCalled = 0;
311 + let bubbledSubmit = false;
312 +
313 + function outerAction(formData) {
314 + outerCalled++;
315 + }
316 +
317 + function innerAction(formData) {
318 + innerCalled++;
319 + }
320 +
321 + const innerContainer = document.createElement('div');
322 + const innerContainerRef = React.createRef();
323 + const outerRoot = ReactDOMClient.createRoot(container);
324 + await act(async () => {
325 + outerRoot.render(
326 + // Nesting forms isn't valid HTML but just in case.
327 + <div onSubmit={() => (bubbledSubmit = true)}>
328 + <form action={outerAction}>
329 + <div ref={innerContainerRef} />
330 + {ReactDOM.createPortal(
331 + <form action={innerAction} ref={ref}>
332 + <input type="text" name="data" defaultValue="inner" />
333 + </form>,
334 + innerContainer,
335 + )}
336 + </form>
337 + </div>,
338 + );
339 + });
340 +
341 + innerContainerRef.current.appendChild(innerContainer);
342 +
343 + submit(ref.current);
344 +
345 + expect(bubbledSubmit).toBe(true);
346 + expect(outerCalled).toBe(0);
347 + expect(innerCalled).toBe(1);
348 + });
349 +
350 + // @gate enableFormActions
351 + it('can read the clicked button in the formdata event', async () => {
352 + const ref = React.createRef();
353 + let button;
354 + let title;
355 +
356 + function action(formData) {
357 + button = formData.get('button');
358 + title = formData.get('title');
359 + }
360 +
361 + const root = ReactDOMClient.createRoot(container);
362 + await act(async () => {
363 + root.render(
364 + // TODO: Test button element too.
365 + <form action={action}>
366 + <input type="text" name="title" defaultValue="hello" />
367 + <input type="submit" name="button" value="save" />
368 + <input type="submit" name="button" value="delete" ref={ref} />
369 + </form>,
370 + );
371 + });
372 +
373 + container.addEventListener('formdata', e => {
374 + // Process in the formdata event somehow
375 + if (e.formData.get('button') === 'delete') {
376 + e.formData.delete('title');
377 + }
378 + });
379 +
380 + submit(ref.current);
381 +
382 + expect(button).toBe('delete');
383 + expect(title).toBe(null);
384 + });
385 +
386 + // @gate enableFormActions || !__DEV__
387 + it('allows a non-function formaction to override a function one', async () => {
388 + const ref = React.createRef();
389 + let actionCalled = false;
390 +
391 + function action(formData) {
392 + actionCalled = true;
393 + }
394 +
395 + const root = ReactDOMClient.createRoot(container);
396 + await act(async () => {
397 + root.render(
398 + <form action={action}>
399 + <input
400 + type="submit"
401 + formAction="http://example.com/submit"
402 + ref={ref}
403 + />
404 + </form>,
405 + );
406 + });
407 +
408 + let nav;
409 + try {
410 + submit(ref.current);
411 + } catch (x) {
412 + nav = x.message;
413 + }
414 + expect(nav).toBe('Navigate to: http://example.com/submit');
415 + expect(actionCalled).toBe(false);
416 + });
417 +
418 + // @gate enableFormActions || !__DEV__
419 + it('allows a non-react html formaction to be invoked', async () => {
420 + let actionCalled = false;
421 +
422 + function action(formData) {
423 + actionCalled = true;
424 + }
425 +
426 + const root = ReactDOMClient.createRoot(container);
427 + await act(async () => {
428 + root.render(
429 + <form
430 + action={action}
431 + dangerouslySetInnerHTML={{
432 + __html: `
433 + <input
434 + type="submit"
435 + formAction="http://example.com/submit"
436 + />
437 + `,
438 + }}
439 + />,
440 + );
441 + });
442 +
443 + const node = container.getElementsByTagName('input')[0];
444 + let nav;
445 + try {
446 + submit(node);
447 + } catch (x) {
448 + nav = x.message;
449 + }
450 + expect(nav).toBe('Navigate to: http://example.com/submit');
451 + expect(actionCalled).toBe(false);
452 + });
453 +});
packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js
+11 -6
@@ -108,12 +108,15 @@ describe('ReactDOMServerIntegration - Untrusted URLs', () => {
108 expect(e.action).toBe('javascript:notfine');
109 });
110
111 - itRenders('a javascript protocol button formAction', async render => {
112 - const e = await render(<input formAction="javascript:notfine" />, 1);
111 + itRenders('a javascript protocol input formAction', async render => {
112 + const e = await render(
113 + <input type="submit" formAction="javascript:notfine" />,
114 + 1,
115 + );
116 expect(e.getAttribute('formAction')).toBe('javascript:notfine');
117 });
118
116 - itRenders('a javascript protocol input formAction', async render => {
119 + itRenders('a javascript protocol button formAction', async render => {
120 const e = await render(
121 <button formAction="javascript:notfine">p0wned</button>,
122 1,
@@ -268,12 +271,14 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', (
271 expect(e.action).toBe(EXPECTED_SAFE_URL);
272 });
273
271 - itRenders('a javascript protocol button formAction', async render => {
272 - const e = await render(<input formAction="javascript:notfine" />);
274 + itRenders('a javascript protocol input formAction', async render => {
275 + const e = await render(
276 + <input type="submit" formAction="javascript:notfine" />,
277 + );
278 expect(e.getAttribute('formAction')).toBe(EXPECTED_SAFE_URL);
279 });
280
276 - itRenders('a javascript protocol input formAction', async render => {
281 + itRenders('a javascript protocol button formAction', async render => {
282 const e = await render(
283 <button formAction="javascript:notfine">p0wned</button>,
284 );
packages/shared/ReactFeatureFlags.js
+2
@@ -85,6 +85,8 @@ export const enableLegacyCache = __EXPERIMENTAL__;
85 export const enableCacheElement = __EXPERIMENTAL__;
86 export const enableFetchInstrumentation = true;
87
88 +export const enableFormActions = __EXPERIMENTAL__;
89 +
90 export const enableTransitionTracing = false;
91
92 // No known bugs, but needs performance testing
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -32,6 +32,7 @@ export const enableCache = false;
32 export const enableLegacyCache = false;
33 export const enableCacheElement = true;
34 export const enableFetchInstrumentation = false;
35 +export const enableFormActions = true; // Doesn't affect Native
36 export const enableSchedulerDebugging = false;
37 export const debugRenderPhaseSideEffectsForStrictMode = true;
38 export const disableJavaScriptURLs = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -23,6 +23,7 @@ export const enableCache = false;
23 export const enableLegacyCache = false;
24 export const enableCacheElement = false;
25 export const enableFetchInstrumentation = false;
26 +export const enableFormActions = true; // Doesn't affect Native
27 export const disableJavaScriptURLs = false;
28 export const disableCommentsAsDOMContainers = true;
29 export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -23,6 +23,7 @@ export const enableCache = true;
23 export const enableLegacyCache = __EXPERIMENTAL__;
24 export const enableCacheElement = __EXPERIMENTAL__;
25 export const enableFetchInstrumentation = true;
26 +export const enableFormActions = true; // Doesn't affect Test Renderer
27 export const disableJavaScriptURLs = false;
28 export const disableCommentsAsDOMContainers = true;
29 export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -23,6 +23,7 @@ export const enableCache = true;
23 export const enableLegacyCache = false;
24 export const enableCacheElement = true;
25 export const enableFetchInstrumentation = false;
26 +export const enableFormActions = true; // Doesn't affect Test Renderer
27 export const disableJavaScriptURLs = false;
28 export const disableCommentsAsDOMContainers = true;
29 export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -23,6 +23,7 @@ export const enableCache = true;
23 export const enableLegacyCache = true;
24 export const enableCacheElement = true;
25 export const enableFetchInstrumentation = false;
26 +export const enableFormActions = true; // Doesn't affect Test Renderer
27 export const enableSchedulerDebugging = false;
28 export const disableJavaScriptURLs = false;
29 export const disableCommentsAsDOMContainers = true;
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -73,6 +73,8 @@ export const enableLegacyCache = true;
73 export const enableCacheElement = true;
74 export const enableFetchInstrumentation = false;
75
76 +export const enableFormActions = true;
77 +
78 export const disableJavaScriptURLs = true;
79
80 // TODO: www currently relies on this feature. It's disabled in open source.