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.
This commit is contained in:
parent
cd2b79dedd
commit
c826dc50de
|
@ -6,20 +6,22 @@ export default function Button({action, children}) {
|
|||
const [isPending, setIsPending] = React.useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isPending}
|
||||
onClick={async () => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
const result = await action();
|
||||
console.log(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
<form>
|
||||
<button
|
||||
disabled={isPending}
|
||||
formAction={async () => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
const result = await action();
|
||||
console.log(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,11 +7,9 @@ export default function Form({action, children}) {
|
|||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
action={async formData => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
const formData = new FormData(e.target);
|
||||
const result = await action(formData);
|
||||
alert(result);
|
||||
} catch (error) {
|
||||
|
|
|
@ -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 <input> or <button>. Use the action prop on <form>.',
|
||||
);
|
||||
} else if (typeof value === 'function') {
|
||||
if (
|
||||
(props.encType != null || props.method != null) &&
|
||||
!didWarnFormActionMethod
|
||||
) {
|
||||
didWarnFormActionMethod = true;
|
||||
console.error(
|
||||
'Cannot specify a encType or method for a form that specifies a ' +
|
||||
'function as the action. React provides those automatically. ' +
|
||||
'They will get overridden.',
|
||||
);
|
||||
}
|
||||
if (props.target != null && !didWarnFormActionTarget) {
|
||||
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 window.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (tag === 'input' || tag === 'button') {
|
||||
if (key === 'action') {
|
||||
console.error(
|
||||
'You can only pass the action prop to <form>. Use the formAction prop on <input> or <button>.',
|
||||
);
|
||||
} else if (
|
||||
tag === 'input' &&
|
||||
props.type !== 'submit' &&
|
||||
props.type !== 'image' &&
|
||||
!didWarnFormActionType
|
||||
) {
|
||||
didWarnFormActionType = true;
|
||||
console.error(
|
||||
'An input can only specify a formAction along with type="submit" or type="image".',
|
||||
);
|
||||
} else if (
|
||||
tag === 'button' &&
|
||||
props.type != null &&
|
||||
props.type !== 'submit' &&
|
||||
!didWarnFormActionType
|
||||
) {
|
||||
didWarnFormActionType = true;
|
||||
console.error(
|
||||
'A button can only specify a formAction along with type="submit" or no type.',
|
||||
);
|
||||
} else if (typeof value === 'function') {
|
||||
// Function form actions cannot control the form properties
|
||||
if (props.name != null && !didWarnFormActionName) {
|
||||
didWarnFormActionName = true;
|
||||
console.error(
|
||||
'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 (
|
||||
(props.formEncType != null || props.formMethod != null) &&
|
||||
!didWarnFormActionMethod
|
||||
) {
|
||||
didWarnFormActionMethod = true;
|
||||
console.error(
|
||||
'Cannot specify a formEncType or formMethod for a button that specifies a ' +
|
||||
'function as a formAction. React provides those automatically. They will get overridden.',
|
||||
);
|
||||
}
|
||||
if (props.formTarget != null && !didWarnFormActionTarget) {
|
||||
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 window.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (key === 'action') {
|
||||
console.error('You can only pass the action prop to <form>.');
|
||||
} else {
|
||||
console.error(
|
||||
'You can only pass the formAction prop to <input> or <button>.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function warnForPropDifference(
|
||||
propName: string,
|
||||
serverValue: mixed,
|
||||
|
@ -327,8 +428,7 @@ function setProp(
|
|||
}
|
||||
// These attributes accept URLs. These must not allow javascript: URLS.
|
||||
case 'src':
|
||||
case 'href':
|
||||
case 'action':
|
||||
case 'href': {
|
||||
if (enableFilterEmptyStringAttributesDOM) {
|
||||
if (value === '') {
|
||||
if (__DEV__) {
|
||||
|
@ -355,8 +455,6 @@ function setProp(
|
|||
break;
|
||||
}
|
||||
}
|
||||
// Fall through to the last case which shouldn't remove empty strings.
|
||||
case 'formAction': {
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'function' ||
|
||||
|
@ -377,6 +475,50 @@ function setProp(
|
|||
domElement.setAttribute(key, sanitizedValue);
|
||||
break;
|
||||
}
|
||||
case 'action':
|
||||
case 'formAction': {
|
||||
// TODO: Consider moving these special cases to the form, input and button tags.
|
||||
if (
|
||||
value == null ||
|
||||
(!enableFormActions && typeof value === 'function') ||
|
||||
typeof value === 'symbol' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
domElement.removeAttribute(key);
|
||||
break;
|
||||
}
|
||||
if (__DEV__) {
|
||||
validateFormActionInDevelopment(tag, key, value, props);
|
||||
}
|
||||
if (enableFormActions && typeof value === 'function') {
|
||||
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
|
||||
// because we'll preventDefault, but it can happen if a form is manually submitted or
|
||||
// if someone calls stopPropagation before React gets the event.
|
||||
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
|
||||
// error message but the URL will be logged.
|
||||
domElement.setAttribute(
|
||||
key,
|
||||
// eslint-disable-next-line no-script-url
|
||||
"javascript:throw new Error('" +
|
||||
'A React form was unexpectedly submitted. If you called form.submit() manually, ' +
|
||||
"consider using form.requestSubmit() instead. If you're trying to use " +
|
||||
'event.stopPropagation() in a submit event handler, consider also calling ' +
|
||||
'event.preventDefault().' +
|
||||
"')",
|
||||
);
|
||||
break;
|
||||
}
|
||||
// `setAttribute` with objects becomes only `[object]` in IE8/9,
|
||||
// ('' + value) makes it output the correct toString()-value.
|
||||
if (__DEV__) {
|
||||
checkAttributeStringCoercion(value, key);
|
||||
}
|
||||
const sanitizedValue = (sanitizeURL(
|
||||
enableTrustedTypesIntegration ? value : '' + (value: any),
|
||||
): any);
|
||||
domElement.setAttribute(key, sanitizedValue);
|
||||
break;
|
||||
}
|
||||
case 'onClick': {
|
||||
// TODO: This cast may not be sound for SVG, MathML or custom elements.
|
||||
if (value != null) {
|
||||
|
@ -2423,6 +2565,13 @@ function diffHydratedCustomComponent(
|
|||
}
|
||||
}
|
||||
|
||||
// This is the exact URL string we expect that Fizz renders if we provide a function action.
|
||||
// We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense
|
||||
// as a shared module for that reason.
|
||||
const EXPECTED_FORM_ACTION_URL =
|
||||
// eslint-disable-next-line no-script-url
|
||||
"javascript:throw new Error('A React form was unexpectedly submitted.')";
|
||||
|
||||
function diffHydratedGenericElement(
|
||||
domElement: Element,
|
||||
tag: string,
|
||||
|
@ -2505,7 +2654,6 @@ function diffHydratedGenericElement(
|
|||
}
|
||||
case 'src':
|
||||
case 'href':
|
||||
case 'action':
|
||||
if (enableFilterEmptyStringAttributesDOM) {
|
||||
if (value === '') {
|
||||
if (__DEV__) {
|
||||
|
@ -2546,11 +2694,29 @@ function diffHydratedGenericElement(
|
|||
extraAttributes,
|
||||
);
|
||||
continue;
|
||||
case 'action':
|
||||
case 'formAction':
|
||||
if (enableFormActions) {
|
||||
const serverValue = domElement.getAttribute(propKey);
|
||||
const hasFormActionURL = serverValue === EXPECTED_FORM_ACTION_URL;
|
||||
if (typeof value === 'function') {
|
||||
extraAttributes.delete(propKey.toLowerCase());
|
||||
if (hasFormActionURL) {
|
||||
// Expected
|
||||
continue;
|
||||
}
|
||||
warnForPropDifference(propKey, serverValue, value);
|
||||
continue;
|
||||
} else if (hasFormActionURL) {
|
||||
extraAttributes.delete(propKey.toLowerCase());
|
||||
warnForPropDifference(propKey, 'function', value);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
hydrateSanitizedAttribute(
|
||||
domElement,
|
||||
propKey,
|
||||
'formaction',
|
||||
propKey.toLowerCase(),
|
||||
value,
|
||||
extraAttributes,
|
||||
);
|
||||
|
|
|
@ -54,6 +54,7 @@ import {
|
|||
enableScopeAPI,
|
||||
enableFloat,
|
||||
enableHostSingletons,
|
||||
enableFormActions,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
invokeGuardedCallbackAndCatchFirstError,
|
||||
|
@ -72,6 +73,7 @@ import * as ChangeEventPlugin from './plugins/ChangeEventPlugin';
|
|||
import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin';
|
||||
import * as SelectEventPlugin from './plugins/SelectEventPlugin';
|
||||
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
|
||||
import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
|
||||
|
||||
type DispatchListener = {
|
||||
instance: null | Fiber,
|
||||
|
@ -173,6 +175,17 @@ function extractEvents(
|
|||
eventSystemFlags,
|
||||
targetContainer,
|
||||
);
|
||||
if (enableFormActions) {
|
||||
FormActionEventPlugin.extractEvents(
|
||||
dispatchQueue,
|
||||
domEventName,
|
||||
targetInst,
|
||||
nativeEvent,
|
||||
nativeEventTarget,
|
||||
eventSystemFlags,
|
||||
targetContainer,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import type {AnyNativeEvent} from '../PluginModuleType';
|
||||
import type {DOMEventName} from '../DOMEventNames';
|
||||
import type {DispatchQueue} from '../DOMPluginEventSystem';
|
||||
import type {EventSystemFlags} from '../EventSystemFlags';
|
||||
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
|
||||
|
||||
import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
|
||||
|
||||
import {SyntheticEvent} from '../SyntheticEvent';
|
||||
|
||||
/**
|
||||
* This plugin invokes action functions on forms, inputs and buttons if
|
||||
* the form doesn't prevent default.
|
||||
*/
|
||||
function extractEvents(
|
||||
dispatchQueue: DispatchQueue,
|
||||
domEventName: DOMEventName,
|
||||
targetInst: null | Fiber,
|
||||
nativeEvent: AnyNativeEvent,
|
||||
nativeEventTarget: null | EventTarget,
|
||||
eventSystemFlags: EventSystemFlags,
|
||||
targetContainer: EventTarget,
|
||||
) {
|
||||
if (domEventName !== 'submit') {
|
||||
return;
|
||||
}
|
||||
if (!targetInst || targetInst.stateNode !== nativeEventTarget) {
|
||||
// If we're inside a parent root that itself is a parent of this root, then
|
||||
// its deepest target won't be the actual form that's being submitted.
|
||||
return;
|
||||
}
|
||||
const form: HTMLFormElement = (nativeEventTarget: any);
|
||||
let action = (getFiberCurrentPropsFromNode(form): any).action;
|
||||
const submitter: null | HTMLInputElement | HTMLButtonElement =
|
||||
(nativeEvent: any).submitter;
|
||||
let submitterAction;
|
||||
if (submitter) {
|
||||
const submitterProps = getFiberCurrentPropsFromNode(submitter);
|
||||
submitterAction = submitterProps
|
||||
? (submitterProps: any).formAction
|
||||
: submitter.getAttribute('formAction');
|
||||
if (submitterAction != null) {
|
||||
// The submitter overrides the form action.
|
||||
action = submitterAction;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof action !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = new SyntheticEvent(
|
||||
'action',
|
||||
'action',
|
||||
null,
|
||||
nativeEvent,
|
||||
nativeEventTarget,
|
||||
);
|
||||
|
||||
function submitForm() {
|
||||
if (nativeEvent.defaultPrevented) {
|
||||
// We let earlier events to prevent the action from submitting.
|
||||
return;
|
||||
}
|
||||
// Prevent native navigation.
|
||||
event.preventDefault();
|
||||
let formData;
|
||||
if (submitter) {
|
||||
// The submitter's value should be included in the FormData.
|
||||
// It should be in the document order in the form.
|
||||
// Since the FormData constructor invokes the formdata event it also
|
||||
// needs to be available before that happens so after construction it's too
|
||||
// late. The easiest way to do this is to switch the form field to hidden,
|
||||
// which is always included, and then back again. This does means that this
|
||||
// is observable from the formdata event though.
|
||||
// TODO: This tricky doesn't work on button elements. Consider inserting
|
||||
// a fake node instead for that case.
|
||||
// TODO: FormData takes a second argument that it's the submitter but this
|
||||
// is fairly new so not all browsers support it yet. Switch to that technique
|
||||
// when available.
|
||||
const type = submitter.type;
|
||||
submitter.type = 'hidden';
|
||||
formData = new FormData(form);
|
||||
submitter.type = type;
|
||||
} else {
|
||||
formData = new FormData(form);
|
||||
}
|
||||
// TODO: Deal with errors and pending state.
|
||||
action(formData);
|
||||
}
|
||||
|
||||
dispatchQueue.push({
|
||||
event,
|
||||
listeners: [
|
||||
{
|
||||
instance: null,
|
||||
listener: submitForm,
|
||||
currentTarget: form,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export {extractEvents};
|
|
@ -21,6 +21,7 @@ import {
|
|||
enableFilterEmptyStringAttributesDOM,
|
||||
enableCustomElementPropertySupport,
|
||||
enableFloat,
|
||||
enableFormActions,
|
||||
enableFizzExternalRuntime,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
|
||||
|
@ -635,6 +636,83 @@ function pushStringAttribute(
|
|||
}
|
||||
}
|
||||
|
||||
// Since this will likely be repeated a lot in the HTML, we use a more concise message
|
||||
// than on the client and hopefully it's googleable.
|
||||
const actionJavaScriptURL = stringToPrecomputedChunk(
|
||||
escapeTextForBrowser(
|
||||
// eslint-disable-next-line no-script-url
|
||||
"javascript:throw new Error('A React form was unexpectedly submitted.')",
|
||||
),
|
||||
);
|
||||
|
||||
function pushFormActionAttribute(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
formAction: any,
|
||||
formEncType: any,
|
||||
formMethod: any,
|
||||
formTarget: any,
|
||||
name: any,
|
||||
): void {
|
||||
if (enableFormActions && typeof formAction === 'function') {
|
||||
// Function form actions cannot control the form properties
|
||||
if (__DEV__) {
|
||||
if (name !== null && !didWarnFormActionName) {
|
||||
didWarnFormActionName = true;
|
||||
console.error(
|
||||
'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 (
|
||||
(formEncType !== null || formMethod !== null) &&
|
||||
!didWarnFormActionMethod
|
||||
) {
|
||||
didWarnFormActionMethod = true;
|
||||
console.error(
|
||||
'Cannot specify a formEncType or formMethod for a button that specifies a ' +
|
||||
'function as a formAction. React provides those automatically. They will get overridden.',
|
||||
);
|
||||
}
|
||||
if (formTarget !== null && !didWarnFormActionTarget) {
|
||||
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 window.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
|
||||
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
|
||||
// manually submitted or if someone calls stopPropagation before React gets the event.
|
||||
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
|
||||
// error message but the URL will be logged.
|
||||
target.push(
|
||||
attributeSeparator,
|
||||
stringToChunk('formAction'),
|
||||
attributeAssign,
|
||||
actionJavaScriptURL,
|
||||
attributeEnd,
|
||||
);
|
||||
} else {
|
||||
// Plain form actions support all the properties, so we have to emit them.
|
||||
if (name !== null) {
|
||||
pushAttribute(target, 'name', name);
|
||||
}
|
||||
if (formAction !== null) {
|
||||
pushAttribute(target, 'formAction', formAction);
|
||||
}
|
||||
if (formEncType !== null) {
|
||||
pushAttribute(target, 'formEncType', formEncType);
|
||||
}
|
||||
if (formMethod !== null) {
|
||||
pushAttribute(target, 'formMethod', formMethod);
|
||||
}
|
||||
if (formTarget !== null) {
|
||||
pushAttribute(target, 'formTarget', formTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pushAttribute(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
name: string,
|
||||
|
@ -665,8 +743,7 @@ function pushAttribute(
|
|||
return;
|
||||
}
|
||||
case 'src':
|
||||
case 'href':
|
||||
case 'action':
|
||||
case 'href': {
|
||||
if (enableFilterEmptyStringAttributesDOM) {
|
||||
if (value === '') {
|
||||
if (__DEV__) {
|
||||
|
@ -692,8 +769,11 @@ function pushAttribute(
|
|||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall through to the last case which shouldn't remove empty strings.
|
||||
case 'action':
|
||||
case 'formAction': {
|
||||
// TODO: Consider only special casing these for each tag.
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'function' ||
|
||||
|
@ -970,6 +1050,10 @@ let didWarnDefaultTextareaValue = false;
|
|||
let didWarnInvalidOptionChildren = false;
|
||||
let didWarnInvalidOptionInnerHTML = false;
|
||||
let didWarnSelectedSetOnOption = false;
|
||||
let didWarnFormActionType = false;
|
||||
let didWarnFormActionName = false;
|
||||
let didWarnFormActionTarget = false;
|
||||
let didWarnFormActionMethod = false;
|
||||
|
||||
function checkSelectProp(props: any, propName: string) {
|
||||
if (__DEV__) {
|
||||
|
@ -1182,51 +1266,127 @@ function pushStartOption(
|
|||
return children;
|
||||
}
|
||||
|
||||
function pushStartForm(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
props: Object,
|
||||
): ReactNodeList {
|
||||
target.push(startChunkForTag('form'));
|
||||
|
||||
let children = null;
|
||||
let innerHTML = null;
|
||||
let formAction = null;
|
||||
let formEncType = null;
|
||||
let formMethod = null;
|
||||
let formTarget = null;
|
||||
|
||||
for (const propKey in props) {
|
||||
if (hasOwnProperty.call(props, propKey)) {
|
||||
const propValue = props[propKey];
|
||||
if (propValue == null) {
|
||||
continue;
|
||||
}
|
||||
switch (propKey) {
|
||||
case 'children':
|
||||
children = propValue;
|
||||
break;
|
||||
case 'dangerouslySetInnerHTML':
|
||||
innerHTML = propValue;
|
||||
break;
|
||||
case 'action':
|
||||
formAction = propValue;
|
||||
break;
|
||||
case 'encType':
|
||||
formEncType = propValue;
|
||||
break;
|
||||
case 'method':
|
||||
formMethod = propValue;
|
||||
break;
|
||||
case 'target':
|
||||
formTarget = propValue;
|
||||
break;
|
||||
default:
|
||||
pushAttribute(target, propKey, propValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enableFormActions && typeof formAction === 'function') {
|
||||
// Function form actions cannot control the form properties
|
||||
if (__DEV__) {
|
||||
if (
|
||||
(formEncType !== null || formMethod !== null) &&
|
||||
!didWarnFormActionMethod
|
||||
) {
|
||||
didWarnFormActionMethod = true;
|
||||
console.error(
|
||||
'Cannot specify a encType or method for a form that specifies a ' +
|
||||
'function as the action. React provides those automatically. ' +
|
||||
'They will get overridden.',
|
||||
);
|
||||
}
|
||||
if (formTarget !== null && !didWarnFormActionTarget) {
|
||||
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 window.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
|
||||
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
|
||||
// manually submitted or if someone calls stopPropagation before React gets the event.
|
||||
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
|
||||
// error message but the URL will be logged.
|
||||
target.push(
|
||||
attributeSeparator,
|
||||
stringToChunk('action'),
|
||||
attributeAssign,
|
||||
actionJavaScriptURL,
|
||||
attributeEnd,
|
||||
);
|
||||
} else {
|
||||
// Plain form actions support all the properties, so we have to emit them.
|
||||
if (formAction !== null) {
|
||||
pushAttribute(target, 'action', formAction);
|
||||
}
|
||||
if (formEncType !== null) {
|
||||
pushAttribute(target, 'encType', formEncType);
|
||||
}
|
||||
if (formMethod !== null) {
|
||||
pushAttribute(target, 'method', formMethod);
|
||||
}
|
||||
if (formTarget !== null) {
|
||||
pushAttribute(target, 'target', formTarget);
|
||||
}
|
||||
}
|
||||
|
||||
target.push(endOfStartTag);
|
||||
pushInnerHTML(target, innerHTML, children);
|
||||
if (typeof children === 'string') {
|
||||
// Special case children as a string to avoid the unnecessary comment.
|
||||
// TODO: Remove this special case after the general optimization is in place.
|
||||
target.push(stringToChunk(encodeHTMLTextNode(children)));
|
||||
return null;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function pushInput(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
props: Object,
|
||||
): ReactNodeList {
|
||||
if (__DEV__) {
|
||||
checkControlledValueProps('input', props);
|
||||
|
||||
if (
|
||||
props.checked !== undefined &&
|
||||
props.defaultChecked !== undefined &&
|
||||
!didWarnDefaultChecked
|
||||
) {
|
||||
console.error(
|
||||
'%s contains an input of type %s with both checked and defaultChecked props. ' +
|
||||
'Input elements must be either controlled or uncontrolled ' +
|
||||
'(specify either the checked prop, or the defaultChecked prop, but not ' +
|
||||
'both). Decide between using a controlled or uncontrolled input ' +
|
||||
'element and remove one of these props. More info: ' +
|
||||
'https://reactjs.org/link/controlled-components',
|
||||
'A component',
|
||||
props.type,
|
||||
);
|
||||
didWarnDefaultChecked = true;
|
||||
}
|
||||
if (
|
||||
props.value !== undefined &&
|
||||
props.defaultValue !== undefined &&
|
||||
!didWarnDefaultInputValue
|
||||
) {
|
||||
console.error(
|
||||
'%s contains an input of type %s with both value and defaultValue props. ' +
|
||||
'Input elements must be either controlled or uncontrolled ' +
|
||||
'(specify either the value prop, or the defaultValue prop, but not ' +
|
||||
'both). Decide between using a controlled or uncontrolled input ' +
|
||||
'element and remove one of these props. More info: ' +
|
||||
'https://reactjs.org/link/controlled-components',
|
||||
'A component',
|
||||
props.type,
|
||||
);
|
||||
didWarnDefaultInputValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
target.push(startChunkForTag('input'));
|
||||
|
||||
let name = null;
|
||||
let formAction = null;
|
||||
let formEncType = null;
|
||||
let formMethod = null;
|
||||
let formTarget = null;
|
||||
let value = null;
|
||||
let defaultValue = null;
|
||||
let checked = null;
|
||||
|
@ -1245,6 +1405,21 @@ function pushInput(
|
|||
`${'input'} is a self-closing tag and must neither have \`children\` nor ` +
|
||||
'use `dangerouslySetInnerHTML`.',
|
||||
);
|
||||
case 'name':
|
||||
name = propValue;
|
||||
break;
|
||||
case 'formAction':
|
||||
formAction = propValue;
|
||||
break;
|
||||
case 'formEncType':
|
||||
formEncType = propValue;
|
||||
break;
|
||||
case 'formMethod':
|
||||
formMethod = propValue;
|
||||
break;
|
||||
case 'formTarget':
|
||||
formTarget = propValue;
|
||||
break;
|
||||
case 'defaultChecked':
|
||||
defaultChecked = propValue;
|
||||
break;
|
||||
|
@ -1264,6 +1439,58 @@ function pushInput(
|
|||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
if (
|
||||
formAction !== null &&
|
||||
props.type !== 'image' &&
|
||||
props.type !== 'submit' &&
|
||||
!didWarnFormActionType
|
||||
) {
|
||||
didWarnFormActionType = true;
|
||||
console.error(
|
||||
'An input can only specify a formAction along with type="submit" or type="image".',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pushFormActionAttribute(
|
||||
target,
|
||||
formAction,
|
||||
formEncType,
|
||||
formMethod,
|
||||
formTarget,
|
||||
name,
|
||||
);
|
||||
|
||||
if (__DEV__) {
|
||||
if (checked !== null && defaultChecked !== null && !didWarnDefaultChecked) {
|
||||
console.error(
|
||||
'%s contains an input of type %s with both checked and defaultChecked props. ' +
|
||||
'Input elements must be either controlled or uncontrolled ' +
|
||||
'(specify either the checked prop, or the defaultChecked prop, but not ' +
|
||||
'both). Decide between using a controlled or uncontrolled input ' +
|
||||
'element and remove one of these props. More info: ' +
|
||||
'https://reactjs.org/link/controlled-components',
|
||||
'A component',
|
||||
props.type,
|
||||
);
|
||||
didWarnDefaultChecked = true;
|
||||
}
|
||||
if (value !== null && defaultValue !== null && !didWarnDefaultInputValue) {
|
||||
console.error(
|
||||
'%s contains an input of type %s with both value and defaultValue props. ' +
|
||||
'Input elements must be either controlled or uncontrolled ' +
|
||||
'(specify either the value prop, or the defaultValue prop, but not ' +
|
||||
'both). Decide between using a controlled or uncontrolled input ' +
|
||||
'element and remove one of these props. More info: ' +
|
||||
'https://reactjs.org/link/controlled-components',
|
||||
'A component',
|
||||
props.type,
|
||||
);
|
||||
didWarnDefaultInputValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (checked !== null) {
|
||||
pushBooleanAttribute(target, 'checked', checked);
|
||||
} else if (defaultChecked !== null) {
|
||||
|
@ -1279,6 +1506,89 @@ function pushInput(
|
|||
return null;
|
||||
}
|
||||
|
||||
function pushStartButton(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
props: Object,
|
||||
): ReactNodeList {
|
||||
target.push(startChunkForTag('button'));
|
||||
|
||||
let children = null;
|
||||
let innerHTML = null;
|
||||
let name = null;
|
||||
let formAction = null;
|
||||
let formEncType = null;
|
||||
let formMethod = null;
|
||||
let formTarget = null;
|
||||
|
||||
for (const propKey in props) {
|
||||
if (hasOwnProperty.call(props, propKey)) {
|
||||
const propValue = props[propKey];
|
||||
if (propValue == null) {
|
||||
continue;
|
||||
}
|
||||
switch (propKey) {
|
||||
case 'children':
|
||||
children = propValue;
|
||||
break;
|
||||
case 'dangerouslySetInnerHTML':
|
||||
innerHTML = propValue;
|
||||
break;
|
||||
case 'name':
|
||||
name = propValue;
|
||||
break;
|
||||
case 'formAction':
|
||||
formAction = propValue;
|
||||
break;
|
||||
case 'formEncType':
|
||||
formEncType = propValue;
|
||||
break;
|
||||
case 'formMethod':
|
||||
formMethod = propValue;
|
||||
break;
|
||||
case 'formTarget':
|
||||
formTarget = propValue;
|
||||
break;
|
||||
default:
|
||||
pushAttribute(target, propKey, propValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
if (
|
||||
formAction !== null &&
|
||||
props.type != null &&
|
||||
props.type !== 'submit' &&
|
||||
!didWarnFormActionType
|
||||
) {
|
||||
didWarnFormActionType = true;
|
||||
console.error(
|
||||
'A button can only specify a formAction along with type="submit" or no type.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pushFormActionAttribute(
|
||||
target,
|
||||
formAction,
|
||||
formEncType,
|
||||
formMethod,
|
||||
formTarget,
|
||||
name,
|
||||
);
|
||||
|
||||
target.push(endOfStartTag);
|
||||
pushInnerHTML(target, innerHTML, children);
|
||||
if (typeof children === 'string') {
|
||||
// Special case children as a string to avoid the unnecessary comment.
|
||||
// TODO: Remove this special case after the general optimization is in place.
|
||||
target.push(stringToChunk(encodeHTMLTextNode(children)));
|
||||
return null;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function pushStartTextArea(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
props: Object,
|
||||
|
@ -2648,6 +2958,10 @@ export function pushStartInstance(
|
|||
return pushStartTextArea(target, props);
|
||||
case 'input':
|
||||
return pushInput(target, props);
|
||||
case 'button':
|
||||
return pushStartButton(target, props);
|
||||
case 'form':
|
||||
return pushStartForm(target, props);
|
||||
case 'menuitem':
|
||||
return pushStartMenuItem(target, props);
|
||||
case 'title':
|
||||
|
@ -2729,7 +3043,7 @@ export function pushStartInstance(
|
|||
case 'font-face-format':
|
||||
case 'font-face-name':
|
||||
case 'missing-glyph': {
|
||||
return pushStartGenericElement(target, props, type);
|
||||
break;
|
||||
}
|
||||
// Preamble start tags
|
||||
case 'head':
|
||||
|
|
|
@ -9,7 +9,10 @@ import {ATTRIBUTE_NAME_CHAR} from './isAttributeNameSafe';
|
|||
import isCustomElement from './isCustomElement';
|
||||
import possibleStandardNames from './possibleStandardNames';
|
||||
import hasOwnProperty from 'shared/hasOwnProperty';
|
||||
import {enableCustomElementPropertySupport} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
enableCustomElementPropertySupport,
|
||||
enableFormActions,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
|
||||
const warnedProperties = {};
|
||||
const EVENT_NAME_REGEX = /^on./;
|
||||
|
@ -38,6 +41,21 @@ function validateProperty(tagName, name, value, eventRegistry) {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (enableFormActions) {
|
||||
// Actions are special because unlike events they can have other value types.
|
||||
if (typeof value === 'function') {
|
||||
if (tagName === 'form' && name === 'action') {
|
||||
return true;
|
||||
}
|
||||
if (tagName === 'input' && name === 'formAction') {
|
||||
return true;
|
||||
}
|
||||
if (tagName === 'button' && name === 'formAction') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We can't rely on the event system being injected on the server.
|
||||
if (eventRegistry != null) {
|
||||
const {registrationNameDependencies, possibleRegistrationNames} =
|
||||
|
|
|
@ -512,25 +512,17 @@ describe('ReactDOMComponent', () => {
|
|||
expect(node.hasAttribute('href')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not add an empty action attribute', () => {
|
||||
it('should allow an empty action attribute', () => {
|
||||
const container = document.createElement('div');
|
||||
expect(() => ReactDOM.render(<form action="" />, container)).toErrorDev(
|
||||
'An empty string ("") was passed to the action attribute. ' +
|
||||
'To fix this, either do not render the element at all ' +
|
||||
'or pass null to action instead of an empty string.',
|
||||
);
|
||||
ReactDOM.render(<form action="" />, container);
|
||||
const node = container.firstChild;
|
||||
expect(node.hasAttribute('action')).toBe(false);
|
||||
expect(node.getAttribute('action')).toBe('');
|
||||
|
||||
ReactDOM.render(<form action="abc" />, container);
|
||||
expect(node.hasAttribute('action')).toBe(true);
|
||||
|
||||
expect(() => ReactDOM.render(<form action="" />, container)).toErrorDev(
|
||||
'An empty string ("") was passed to the action attribute. ' +
|
||||
'To fix this, either do not render the element at all ' +
|
||||
'or pass null to action instead of an empty string.',
|
||||
);
|
||||
expect(node.hasAttribute('action')).toBe(false);
|
||||
ReactDOM.render(<form action="" />, container);
|
||||
expect(node.getAttribute('action')).toBe('');
|
||||
});
|
||||
|
||||
it('allows empty string of a formAction to override the default of a parent', () => {
|
||||
|
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
// Polyfills for test environment
|
||||
global.ReadableStream =
|
||||
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
|
||||
global.TextEncoder = require('util').TextEncoder;
|
||||
|
||||
let act;
|
||||
let container;
|
||||
let React;
|
||||
let ReactDOMServer;
|
||||
let ReactDOMClient;
|
||||
|
||||
describe('ReactDOMFizzForm', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
React = require('react');
|
||||
ReactDOMServer = require('react-dom/server.browser');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function readIntoContainer(stream) {
|
||||
const reader = stream.getReader();
|
||||
let result = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
result += Buffer.from(value).toString('utf8');
|
||||
}
|
||||
container.innerHTML = result;
|
||||
}
|
||||
|
||||
// @gate enableFormActions
|
||||
it('should allow passing a function to form action during SSR', async () => {
|
||||
const ref = React.createRef();
|
||||
let foo;
|
||||
|
||||
function action(formData) {
|
||||
foo = formData.get('foo');
|
||||
}
|
||||
function App() {
|
||||
return (
|
||||
<form action={action} ref={ref}>
|
||||
<input type="text" name="foo" defaultValue="bar" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await ReactDOMServer.renderToReadableStream(<App />);
|
||||
await readIntoContainer(stream);
|
||||
await act(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
|
||||
submit(ref.current);
|
||||
|
||||
expect(foo).toBe('bar');
|
||||
});
|
||||
|
||||
// @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');
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<form action={action}>
|
||||
<input type="text" name="title" defaultValue="Hello" />
|
||||
<input
|
||||
type="submit"
|
||||
formAction={saveItem}
|
||||
value="Save"
|
||||
ref={inputRef}
|
||||
/>
|
||||
<button formAction={deleteItem} ref={buttonRef}>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await ReactDOMServer.renderToReadableStream(<App />);
|
||||
await readIntoContainer(stream);
|
||||
await act(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
|
||||
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 (
|
||||
<form action={isClient ? 'action' : action}>
|
||||
<input type="text" name="foo" defaultValue="bar" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await ReactDOMServer.renderToReadableStream(<App />);
|
||||
await readIntoContainer(stream);
|
||||
await expect(async () => {
|
||||
await act(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
|
||||
});
|
||||
}).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 (
|
||||
<form action={isClient ? action : 'action'}>
|
||||
<input type="text" name="foo" defaultValue="bar" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await ReactDOMServer.renderToReadableStream(<App />);
|
||||
await readIntoContainer(stream);
|
||||
await expect(async () => {
|
||||
await act(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
|
||||
});
|
||||
}).toErrorDev(
|
||||
'Prop `action` did not match. Server: "action" Client: "function action(formData) {}"',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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(
|
||||
<form action={action} ref={ref}>
|
||||
<input type="text" name="foo" defaultValue="bar" />
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
submit(ref.current);
|
||||
|
||||
expect(foo).toBe('bar');
|
||||
|
||||
// Try updating the action
|
||||
|
||||
function action2(formData) {
|
||||
foo = formData.get('foo') + '2';
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<form action={action2} ref={ref}>
|
||||
<input type="text" name="foo" defaultValue="bar" />
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<form action={action}>
|
||||
<input type="text" name="title" defaultValue="Hello" />
|
||||
<input
|
||||
type="submit"
|
||||
formAction={saveItem}
|
||||
value="Save"
|
||||
ref={inputRef}
|
||||
/>
|
||||
<button formAction={deleteItem} ref={buttonRef}>
|
||||
Delete
|
||||
</button>
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<form action={action}>
|
||||
<input type="text" name="title" defaultValue="Hello" />
|
||||
<input
|
||||
type="submit"
|
||||
formAction={saveItem2}
|
||||
value="Save"
|
||||
ref={inputRef}
|
||||
/>
|
||||
<button formAction={deleteItem2} ref={buttonRef}>
|
||||
Delete
|
||||
</button>
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<form action={action} ref={ref} onSubmit={e => e.preventDefault()}>
|
||||
<input type="text" name="foo" defaultValue="bar" />
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<form action={outerAction}>
|
||||
<input type="text" name="data" defaultValue="outer" />
|
||||
<form action={innerAction} ref={ref}>
|
||||
<input type="text" name="data" defaultValue="inner" />
|
||||
</form>
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
}).toErrorDev([
|
||||
'Warning: validateDOMNesting(...): <form> cannot appear as a descendant of <form>.' +
|
||||
'\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.
|
||||
<div onSubmit={() => (bubbledSubmit = true)}>
|
||||
<form action={outerAction}>
|
||||
<div ref={innerContainerRef} />
|
||||
</form>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
const innerRoot = ReactDOMClient.createRoot(innerContainerRef.current);
|
||||
await act(async () => {
|
||||
innerRoot.render(
|
||||
<form action={innerAction} ref={ref}>
|
||||
<input type="text" name="data" defaultValue="inner" />
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
<div onSubmit={() => (bubbledSubmit = true)}>
|
||||
<form action={outerAction}>
|
||||
<div ref={innerContainerRef} />
|
||||
{ReactDOM.createPortal(
|
||||
<form action={innerAction} ref={ref}>
|
||||
<input type="text" name="data" defaultValue="inner" />
|
||||
</form>,
|
||||
innerContainer,
|
||||
)}
|
||||
</form>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
<form action={action}>
|
||||
<input type="text" name="title" defaultValue="hello" />
|
||||
<input type="submit" name="button" value="save" />
|
||||
<input type="submit" name="button" value="delete" ref={ref} />
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<form action={action}>
|
||||
<input
|
||||
type="submit"
|
||||
formAction="http://example.com/submit"
|
||||
ref={ref}
|
||||
/>
|
||||
</form>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<form
|
||||
action={action}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<input
|
||||
type="submit"
|
||||
formAction="http://example.com/submit"
|
||||
/>
|
||||
`,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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(<input formAction="javascript:notfine" />, 1);
|
||||
itRenders('a javascript protocol input formAction', async render => {
|
||||
const e = await render(
|
||||
<input type="submit" formAction="javascript:notfine" />,
|
||||
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(
|
||||
<button formAction="javascript:notfine">p0wned</button>,
|
||||
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(<input formAction="javascript:notfine" />);
|
||||
itRenders('a javascript protocol input formAction', async render => {
|
||||
const e = await render(
|
||||
<input type="submit" formAction="javascript:notfine" />,
|
||||
);
|
||||
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(
|
||||
<button formAction="javascript:notfine">p0wned</button>,
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue