Skip to content

Commit 06fbf92

Browse files
sebmarkbagekassens
authored andcommitted
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.
1 parent 331d0da commit 06fbf92

18 files changed

+1363
-82
lines changed

fixtures/flight/src/Button.js

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@ export default function Button({action, children}) {
66
const [isPending, setIsPending] = React.useState(false);
77

88
return (
9-
<button
10-
disabled={isPending}
11-
onClick={async () => {
12-
setIsPending(true);
13-
try {
14-
const result = await action();
15-
console.log(result);
16-
} catch (error) {
17-
console.error(error);
18-
} finally {
19-
setIsPending(false);
20-
}
21-
}}>
22-
{children}
23-
</button>
9+
<form>
10+
<button
11+
disabled={isPending}
12+
formAction={async () => {
13+
setIsPending(true);
14+
try {
15+
const result = await action();
16+
console.log(result);
17+
} catch (error) {
18+
console.error(error);
19+
} finally {
20+
setIsPending(false);
21+
}
22+
}}>
23+
{children}
24+
</button>
25+
</form>
2426
);
2527
}

fixtures/flight/src/Form.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@ export default function Form({action, children}) {
77

88
return (
99
<form
10-
onSubmit={async e => {
11-
e.preventDefault();
10+
action={async formData => {
1211
setIsPending(true);
1312
try {
14-
const formData = new FormData(e.target);
1513
const result = await action(formData);
1614
alert(result);
1715
} catch (error) {

packages/react-dom-bindings/src/client/ReactDOMComponent.js

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import sanitizeURL from '../shared/sanitizeURL';
6565
import {
6666
enableCustomElementPropertySupport,
6767
enableClientRenderFallbackOnTextMismatch,
68+
enableFormActions,
6869
enableHostSingletons,
6970
disableIEWorkarounds,
7071
enableTrustedTypesIntegration,
@@ -79,6 +80,10 @@ import {
7980
let didWarnControlledToUncontrolled = false;
8081
let didWarnUncontrolledToControlled = false;
8182
let didWarnInvalidHydration = false;
83+
let didWarnFormActionType = false;
84+
let didWarnFormActionName = false;
85+
let didWarnFormActionTarget = false;
86+
let didWarnFormActionMethod = false;
8287
let canDiffStyleForHydrationWarning;
8388
if (__DEV__) {
8489
// IE 11 parses & normalizes the style attribute as opposed to other
@@ -116,6 +121,102 @@ function validatePropertiesInDevelopment(type: string, props: any) {
116121
}
117122
}
118123

124+
function validateFormActionInDevelopment(
125+
tag: string,
126+
key: string,
127+
value: mixed,
128+
props: any,
129+
) {
130+
if (__DEV__) {
131+
if (tag === 'form') {
132+
if (key === 'formAction') {
133+
console.error(
134+
'You can only pass the formAction prop to <input> or <button>. Use the action prop on <form>.',
135+
);
136+
} else if (typeof value === 'function') {
137+
if (
138+
(props.encType != null || props.method != null) &&
139+
!didWarnFormActionMethod
140+
) {
141+
didWarnFormActionMethod = true;
142+
console.error(
143+
'Cannot specify a encType or method for a form that specifies a ' +
144+
'function as the action. React provides those automatically. ' +
145+
'They will get overridden.',
146+
);
147+
}
148+
if (props.target != null && !didWarnFormActionTarget) {
149+
didWarnFormActionTarget = true;
150+
console.error(
151+
'Cannot specify a target for a form that specifies a function as the action. ' +
152+
'The function will always be executed in the same window.',
153+
);
154+
}
155+
}
156+
} else if (tag === 'input' || tag === 'button') {
157+
if (key === 'action') {
158+
console.error(
159+
'You can only pass the action prop to <form>. Use the formAction prop on <input> or <button>.',
160+
);
161+
} else if (
162+
tag === 'input' &&
163+
props.type !== 'submit' &&
164+
props.type !== 'image' &&
165+
!didWarnFormActionType
166+
) {
167+
didWarnFormActionType = true;
168+
console.error(
169+
'An input can only specify a formAction along with type="submit" or type="image".',
170+
);
171+
} else if (
172+
tag === 'button' &&
173+
props.type != null &&
174+
props.type !== 'submit' &&
175+
!didWarnFormActionType
176+
) {
177+
didWarnFormActionType = true;
178+
console.error(
179+
'A button can only specify a formAction along with type="submit" or no type.',
180+
);
181+
} else if (typeof value === 'function') {
182+
// Function form actions cannot control the form properties
183+
if (props.name != null && !didWarnFormActionName) {
184+
didWarnFormActionName = true;
185+
console.error(
186+
'Cannot specify a "name" prop for a button that specifies a function as a formAction. ' +
187+
'React needs it to encode which action should be invoked. It will get overridden.',
188+
);
189+
}
190+
if (
191+
(props.formEncType != null || props.formMethod != null) &&
192+
!didWarnFormActionMethod
193+
) {
194+
didWarnFormActionMethod = true;
195+
console.error(
196+
'Cannot specify a formEncType or formMethod for a button that specifies a ' +
197+
'function as a formAction. React provides those automatically. They will get overridden.',
198+
);
199+
}
200+
if (props.formTarget != null && !didWarnFormActionTarget) {
201+
didWarnFormActionTarget = true;
202+
console.error(
203+
'Cannot specify a formTarget for a button that specifies a function as a formAction. ' +
204+
'The function will always be executed in the same window.',
205+
);
206+
}
207+
}
208+
} else {
209+
if (key === 'action') {
210+
console.error('You can only pass the action prop to <form>.');
211+
} else {
212+
console.error(
213+
'You can only pass the formAction prop to <input> or <button>.',
214+
);
215+
}
216+
}
217+
}
218+
}
219+
119220
function warnForPropDifference(
120221
propName: string,
121222
serverValue: mixed,
@@ -327,8 +428,7 @@ function setProp(
327428
}
328429
// These attributes accept URLs. These must not allow javascript: URLS.
329430
case 'src':
330-
case 'href':
331-
case 'action':
431+
case 'href': {
332432
if (enableFilterEmptyStringAttributesDOM) {
333433
if (value === '') {
334434
if (__DEV__) {
@@ -355,8 +455,6 @@ function setProp(
355455
break;
356456
}
357457
}
358-
// Fall through to the last case which shouldn't remove empty strings.
359-
case 'formAction': {
360458
if (
361459
value == null ||
362460
typeof value === 'function' ||
@@ -377,6 +475,50 @@ function setProp(
377475
domElement.setAttribute(key, sanitizedValue);
378476
break;
379477
}
478+
case 'action':
479+
case 'formAction': {
480+
// TODO: Consider moving these special cases to the form, input and button tags.
481+
if (
482+
value == null ||
483+
(!enableFormActions && typeof value === 'function') ||
484+
typeof value === 'symbol' ||
485+
typeof value === 'boolean'
486+
) {
487+
domElement.removeAttribute(key);
488+
break;
489+
}
490+
if (__DEV__) {
491+
validateFormActionInDevelopment(tag, key, value, props);
492+
}
493+
if (enableFormActions && typeof value === 'function') {
494+
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
495+
// because we'll preventDefault, but it can happen if a form is manually submitted or
496+
// if someone calls stopPropagation before React gets the event.
497+
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
498+
// error message but the URL will be logged.
499+
domElement.setAttribute(
500+
key,
501+
// eslint-disable-next-line no-script-url
502+
"javascript:throw new Error('" +
503+
'A React form was unexpectedly submitted. If you called form.submit() manually, ' +
504+
"consider using form.requestSubmit() instead. If you're trying to use " +
505+
'event.stopPropagation() in a submit event handler, consider also calling ' +
506+
'event.preventDefault().' +
507+
"')",
508+
);
509+
break;
510+
}
511+
// `setAttribute` with objects becomes only `[object]` in IE8/9,
512+
// ('' + value) makes it output the correct toString()-value.
513+
if (__DEV__) {
514+
checkAttributeStringCoercion(value, key);
515+
}
516+
const sanitizedValue = (sanitizeURL(
517+
enableTrustedTypesIntegration ? value : '' + (value: any),
518+
): any);
519+
domElement.setAttribute(key, sanitizedValue);
520+
break;
521+
}
380522
case 'onClick': {
381523
// TODO: This cast may not be sound for SVG, MathML or custom elements.
382524
if (value != null) {
@@ -2423,6 +2565,13 @@ function diffHydratedCustomComponent(
24232565
}
24242566
}
24252567

2568+
// This is the exact URL string we expect that Fizz renders if we provide a function action.
2569+
// We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense
2570+
// as a shared module for that reason.
2571+
const EXPECTED_FORM_ACTION_URL =
2572+
// eslint-disable-next-line no-script-url
2573+
"javascript:throw new Error('A React form was unexpectedly submitted.')";
2574+
24262575
function diffHydratedGenericElement(
24272576
domElement: Element,
24282577
tag: string,
@@ -2505,7 +2654,6 @@ function diffHydratedGenericElement(
25052654
}
25062655
case 'src':
25072656
case 'href':
2508-
case 'action':
25092657
if (enableFilterEmptyStringAttributesDOM) {
25102658
if (value === '') {
25112659
if (__DEV__) {
@@ -2546,11 +2694,29 @@ function diffHydratedGenericElement(
25462694
extraAttributes,
25472695
);
25482696
continue;
2697+
case 'action':
25492698
case 'formAction':
2699+
if (enableFormActions) {
2700+
const serverValue = domElement.getAttribute(propKey);
2701+
const hasFormActionURL = serverValue === EXPECTED_FORM_ACTION_URL;
2702+
if (typeof value === 'function') {
2703+
extraAttributes.delete(propKey.toLowerCase());
2704+
if (hasFormActionURL) {
2705+
// Expected
2706+
continue;
2707+
}
2708+
warnForPropDifference(propKey, serverValue, value);
2709+
continue;
2710+
} else if (hasFormActionURL) {
2711+
extraAttributes.delete(propKey.toLowerCase());
2712+
warnForPropDifference(propKey, 'function', value);
2713+
continue;
2714+
}
2715+
}
25502716
hydrateSanitizedAttribute(
25512717
domElement,
25522718
propKey,
2553-
'formaction',
2719+
propKey.toLowerCase(),
25542720
value,
25552721
extraAttributes,
25562722
);

packages/react-dom-bindings/src/events/DOMPluginEventSystem.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
enableScopeAPI,
5555
enableFloat,
5656
enableHostSingletons,
57+
enableFormActions,
5758
} from 'shared/ReactFeatureFlags';
5859
import {
5960
invokeGuardedCallbackAndCatchFirstError,
@@ -72,6 +73,7 @@ import * as ChangeEventPlugin from './plugins/ChangeEventPlugin';
7273
import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin';
7374
import * as SelectEventPlugin from './plugins/SelectEventPlugin';
7475
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
76+
import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
7577

7678
type DispatchListener = {
7779
instance: null | Fiber,
@@ -173,6 +175,17 @@ function extractEvents(
173175
eventSystemFlags,
174176
targetContainer,
175177
);
178+
if (enableFormActions) {
179+
FormActionEventPlugin.extractEvents(
180+
dispatchQueue,
181+
domEventName,
182+
targetInst,
183+
nativeEvent,
184+
nativeEventTarget,
185+
eventSystemFlags,
186+
targetContainer,
187+
);
188+
}
176189
}
177190
}
178191

0 commit comments

Comments
 (0)