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:
Sebastian Markbåge 2023-04-19 16:31:08 -04:00 committed by GitHub
parent cd2b79dedd
commit c826dc50de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1363 additions and 82 deletions

View File

@ -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>
);
}

View File

@ -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) {

View File

@ -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,
);

View File

@ -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,
);
}
}
}

View File

@ -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};

View File

@ -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':

View File

@ -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} =

View File

@ -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', () => {

View File

@ -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) {}"',
);
});
});

View File

@ -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);
});
});

View File

@ -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>,
);

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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.