Skip to content

Commit 8dfcf5f

Browse files
committed
Allow an action provide a custom set of props to use for progressive enhancement
1 parent b2b497c commit 8dfcf5f

File tree

5 files changed

+302
-75
lines changed

5 files changed

+302
-75
lines changed

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2791,7 +2791,6 @@ function diffHydratedGenericElement(
27912791
case 'formAction':
27922792
if (enableFormActions) {
27932793
const serverValue = domElement.getAttribute(propKey);
2794-
const hasFormActionURL = serverValue === EXPECTED_FORM_ACTION_URL;
27952794
if (typeof value === 'function') {
27962795
extraAttributes.delete(propKey.toLowerCase());
27972796
// The server can set these extra properties to implement actions.
@@ -2806,13 +2805,14 @@ function diffHydratedGenericElement(
28062805
extraAttributes.delete('method');
28072806
extraAttributes.delete('target');
28082807
}
2809-
if (hasFormActionURL) {
2810-
// Expected
2811-
continue;
2812-
}
2813-
warnForPropDifference(propKey, serverValue, value);
2808+
// Ideally we should be able to warn if the server value was not a function
2809+
// however since the function can return any of these attributes any way it
2810+
// wants as a custom progressive enhancement, there's nothing to compare to.
2811+
// We can check if the function has the $FORM_ACTION property on the client
2812+
// and if it's not, warn, but that's an unnecessary constraint that they
2813+
// have to have the extra extension that doesn't do anything on the client.
28142814
continue;
2815-
} else if (hasFormActionURL) {
2815+
} else if (serverValue === EXPECTED_FORM_ACTION_URL) {
28162816
extraAttributes.delete(propKey.toLowerCase());
28172817
warnForPropDifference(propKey, 'function', value);
28182818
continue;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1445,7 +1445,7 @@ export function shouldDeleteUnhydratedTailInstances(
14451445
return (
14461446
(enableHostSingletons ||
14471447
(parentType !== 'head' && parentType !== 'body')) &&
1448-
(!enableFormActions || parentType !== 'form')
1448+
(!enableFormActions || (parentType !== 'form' && parentType !== 'button'))
14491449
);
14501450
}
14511451

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 158 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,22 @@ function pushStringAttribute(
632632
}
633633
}
634634

635+
type CustomFormAction = {
636+
name?: string,
637+
action?: string,
638+
encType?: string,
639+
method?: string,
640+
target?: string,
641+
data?: FormData,
642+
};
643+
644+
function makeFormFieldPrefix(responseState: ResponseState): string {
645+
// I'm just reusing this counter. It's not really the same namespace as "name".
646+
// It could just be its own counter.
647+
const id = responseState.nextSuspenseID++;
648+
return responseState.idPrefix + '$ACTION:' + id + ':';
649+
}
650+
635651
// Since this will likely be repeated a lot in the HTML, we use a more concise message
636652
// than on the client and hopefully it's googleable.
637653
const actionJavaScriptURL = stringToPrecomputedChunk(
@@ -641,6 +657,36 @@ const actionJavaScriptURL = stringToPrecomputedChunk(
641657
),
642658
);
643659

660+
const startHiddenInputChunk = stringToPrecomputedChunk('<input type="hidden"');
661+
662+
function pushAdditionalFormField(
663+
this: Array<Chunk | PrecomputedChunk>,
664+
value: string | File,
665+
key: string,
666+
): void {
667+
const target: Array<Chunk | PrecomputedChunk> = this;
668+
target.push(startHiddenInputChunk);
669+
if (typeof value !== 'string') {
670+
throw new Error(
671+
'File/Blob fields are not yet supported in progressive forms. ' +
672+
'It probably means you are closing over binary data or FormData in a Server Action.',
673+
);
674+
}
675+
pushStringAttribute(target, 'name', key);
676+
pushStringAttribute(target, 'value', value);
677+
target.push(endOfStartTagSelfClosing);
678+
}
679+
680+
function pushAdditionalFormFields(
681+
target: Array<Chunk | PrecomputedChunk>,
682+
formData: null | FormData,
683+
) {
684+
if (formData !== null) {
685+
// $FlowFixMe[prop-missing]: FormData has forEach.
686+
formData.forEach(pushAdditionalFormField, target);
687+
}
688+
}
689+
644690
function pushFormActionAttribute(
645691
target: Array<Chunk | PrecomputedChunk>,
646692
responseState: ResponseState,
@@ -649,7 +695,8 @@ function pushFormActionAttribute(
649695
formMethod: any,
650696
formTarget: any,
651697
name: any,
652-
): void {
698+
): null | FormData {
699+
let formData = null;
653700
if (enableFormActions && typeof formAction === 'function') {
654701
// Function form actions cannot control the form properties
655702
if (__DEV__) {
@@ -678,37 +725,55 @@ function pushFormActionAttribute(
678725
);
679726
}
680727
}
681-
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
682-
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
683-
// manually submitted or if someone calls stopPropagation before React gets the event.
684-
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
685-
// error message but the URL will be logged.
686-
target.push(
687-
attributeSeparator,
688-
stringToChunk('formAction'),
689-
attributeAssign,
690-
actionJavaScriptURL,
691-
attributeEnd,
692-
);
693-
injectFormReplayingRuntime(responseState);
694-
} else {
695-
// Plain form actions support all the properties, so we have to emit them.
696-
if (name !== null) {
697-
pushAttribute(target, 'name', name);
698-
}
699-
if (formAction !== null) {
700-
pushAttribute(target, 'formAction', formAction);
701-
}
702-
if (formEncType !== null) {
703-
pushAttribute(target, 'formEncType', formEncType);
704-
}
705-
if (formMethod !== null) {
706-
pushAttribute(target, 'formMethod', formMethod);
707-
}
708-
if (formTarget !== null) {
709-
pushAttribute(target, 'formTarget', formTarget);
728+
const customAction: CustomFormAction = formAction.$$FORM_ACTION;
729+
if (typeof customAction === 'function') {
730+
// This action has a custom progressive enhancement form that can submit the form
731+
// back to the server if it's invoked before hydration. Such as a Server Action.
732+
const prefix = makeFormFieldPrefix(responseState);
733+
const customFields = customAction(prefix);
734+
name = customFields.name;
735+
formAction = customFields.action || '';
736+
formEncType = customFields.encType;
737+
formMethod = customFields.method;
738+
formTarget = customFields.target;
739+
formData = customFields.data;
740+
} else {
741+
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
742+
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
743+
// manually submitted or if someone calls stopPropagation before React gets the event.
744+
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
745+
// error message but the URL will be logged.
746+
target.push(
747+
attributeSeparator,
748+
stringToChunk('formAction'),
749+
attributeAssign,
750+
actionJavaScriptURL,
751+
attributeEnd,
752+
);
753+
name = null;
754+
formAction = null;
755+
formEncType = null;
756+
formMethod = null;
757+
formTarget = null;
758+
injectFormReplayingRuntime(responseState);
710759
}
711760
}
761+
if (name !== null) {
762+
pushAttribute(target, 'name', name);
763+
}
764+
if (formAction !== null) {
765+
pushAttribute(target, 'formAction', formAction);
766+
}
767+
if (formEncType !== null) {
768+
pushAttribute(target, 'formEncType', formEncType);
769+
}
770+
if (formMethod !== null) {
771+
pushAttribute(target, 'formMethod', formMethod);
772+
}
773+
if (formTarget !== null) {
774+
pushAttribute(target, 'formTarget', formTarget);
775+
}
776+
return formData;
712777
}
713778

714779
function pushAttribute(
@@ -1330,6 +1395,8 @@ function pushStartForm(
13301395
}
13311396
}
13321397

1398+
let formData = null;
1399+
let formActionName = null;
13331400
if (enableFormActions && typeof formAction === 'function') {
13341401
// Function form actions cannot control the form properties
13351402
if (__DEV__) {
@@ -1352,36 +1419,60 @@ function pushStartForm(
13521419
);
13531420
}
13541421
}
1355-
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
1356-
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
1357-
// manually submitted or if someone calls stopPropagation before React gets the event.
1358-
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
1359-
// error message but the URL will be logged.
1360-
target.push(
1361-
attributeSeparator,
1362-
stringToChunk('action'),
1363-
attributeAssign,
1364-
actionJavaScriptURL,
1365-
attributeEnd,
1366-
);
1367-
injectFormReplayingRuntime(responseState);
1368-
} else {
1369-
// Plain form actions support all the properties, so we have to emit them.
1370-
if (formAction !== null) {
1371-
pushAttribute(target, 'action', formAction);
1372-
}
1373-
if (formEncType !== null) {
1374-
pushAttribute(target, 'encType', formEncType);
1375-
}
1376-
if (formMethod !== null) {
1377-
pushAttribute(target, 'method', formMethod);
1378-
}
1379-
if (formTarget !== null) {
1380-
pushAttribute(target, 'target', formTarget);
1422+
const customAction: CustomFormAction = formAction.$$FORM_ACTION;
1423+
if (typeof customAction === 'function') {
1424+
// This action has a custom progressive enhancement form that can submit the form
1425+
// back to the server if it's invoked before hydration. Such as a Server Action.
1426+
const prefix = makeFormFieldPrefix(responseState);
1427+
const customFields = customAction(prefix);
1428+
formAction = customFields.action || '';
1429+
formEncType = customFields.encType;
1430+
formMethod = customFields.method;
1431+
formTarget = customFields.target;
1432+
formData = customFields.data;
1433+
formActionName = customFields.name;
1434+
} else {
1435+
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
1436+
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
1437+
// manually submitted or if someone calls stopPropagation before React gets the event.
1438+
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
1439+
// error message but the URL will be logged.
1440+
target.push(
1441+
attributeSeparator,
1442+
stringToChunk('action'),
1443+
attributeAssign,
1444+
actionJavaScriptURL,
1445+
attributeEnd,
1446+
);
1447+
formAction = null;
1448+
formEncType = null;
1449+
formMethod = null;
1450+
formTarget = null;
1451+
injectFormReplayingRuntime(responseState);
13811452
}
13821453
}
1454+
if (formAction !== null) {
1455+
pushAttribute(target, 'action', formAction);
1456+
}
1457+
if (formEncType !== null) {
1458+
pushAttribute(target, 'encType', formEncType);
1459+
}
1460+
if (formMethod !== null) {
1461+
pushAttribute(target, 'method', formMethod);
1462+
}
1463+
if (formTarget !== null) {
1464+
pushAttribute(target, 'target', formTarget);
1465+
}
13831466

13841467
target.push(endOfStartTag);
1468+
1469+
if (formActionName !== null) {
1470+
target.push(startHiddenInputChunk);
1471+
pushStringAttribute(target, 'name', formActionName);
1472+
target.push(endOfStartTagSelfClosing);
1473+
pushAdditionalFormFields(target, formData);
1474+
}
1475+
13851476
pushInnerHTML(target, innerHTML, children);
13861477
if (typeof children === 'string') {
13871478
// Special case children as a string to avoid the unnecessary comment.
@@ -1474,7 +1565,7 @@ function pushInput(
14741565
}
14751566
}
14761567

1477-
pushFormActionAttribute(
1568+
const formData = pushFormActionAttribute(
14781569
target,
14791570
responseState,
14801571
formAction,
@@ -1525,6 +1616,10 @@ function pushInput(
15251616
}
15261617

15271618
target.push(endOfStartTagSelfClosing);
1619+
1620+
// We place any additional hidden form fields after the input.
1621+
pushAdditionalFormFields(target, formData);
1622+
15281623
return null;
15291624
}
15301625

@@ -1592,7 +1687,7 @@ function pushStartButton(
15921687
}
15931688
}
15941689

1595-
pushFormActionAttribute(
1690+
const formData = pushFormActionAttribute(
15961691
target,
15971692
responseState,
15981693
formAction,
@@ -1603,13 +1698,18 @@ function pushStartButton(
16031698
);
16041699

16051700
target.push(endOfStartTag);
1701+
1702+
// We place any additional hidden form fields we need to include inside the button itself.
1703+
pushAdditionalFormFields(target, formData);
1704+
16061705
pushInnerHTML(target, innerHTML, children);
16071706
if (typeof children === 'string') {
16081707
// Special case children as a string to avoid the unnecessary comment.
16091708
// TODO: Remove this special case after the general optimization is in place.
16101709
target.push(stringToChunk(encodeHTMLTextNode(children)));
16111710
return null;
16121711
}
1712+
16131713
return children;
16141714
}
16151715

0 commit comments

Comments
 (0)