Skip to content

Commit cbb135a

Browse files
committed
Allow an action provide a custom set of props to use for progressive enhancement
1 parent 67f4fb0 commit cbb135a

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
@@ -668,6 +668,22 @@ function pushStringAttribute(
668668
}
669669
}
670670

671+
type CustomFormAction = {
672+
name?: string,
673+
action?: string,
674+
encType?: string,
675+
method?: string,
676+
target?: string,
677+
data?: FormData,
678+
};
679+
680+
function makeFormFieldPrefix(responseState: ResponseState): string {
681+
// I'm just reusing this counter. It's not really the same namespace as "name".
682+
// It could just be its own counter.
683+
const id = responseState.nextSuspenseID++;
684+
return responseState.idPrefix + '$ACTION:' + id + ':';
685+
}
686+
671687
// Since this will likely be repeated a lot in the HTML, we use a more concise message
672688
// than on the client and hopefully it's googleable.
673689
const actionJavaScriptURL = stringToPrecomputedChunk(
@@ -677,6 +693,36 @@ const actionJavaScriptURL = stringToPrecomputedChunk(
677693
),
678694
);
679695

696+
const startHiddenInputChunk = stringToPrecomputedChunk('<input type="hidden"');
697+
698+
function pushAdditionalFormField(
699+
this: Array<Chunk | PrecomputedChunk>,
700+
value: string | File,
701+
key: string,
702+
): void {
703+
const target: Array<Chunk | PrecomputedChunk> = this;
704+
target.push(startHiddenInputChunk);
705+
if (typeof value !== 'string') {
706+
throw new Error(
707+
'File/Blob fields are not yet supported in progressive forms. ' +
708+
'It probably means you are closing over binary data or FormData in a Server Action.',
709+
);
710+
}
711+
pushStringAttribute(target, 'name', key);
712+
pushStringAttribute(target, 'value', value);
713+
target.push(endOfStartTagSelfClosing);
714+
}
715+
716+
function pushAdditionalFormFields(
717+
target: Array<Chunk | PrecomputedChunk>,
718+
formData: null | FormData,
719+
) {
720+
if (formData !== null) {
721+
// $FlowFixMe[prop-missing]: FormData has forEach.
722+
formData.forEach(pushAdditionalFormField, target);
723+
}
724+
}
725+
680726
function pushFormActionAttribute(
681727
target: Array<Chunk | PrecomputedChunk>,
682728
responseState: ResponseState,
@@ -685,7 +731,8 @@ function pushFormActionAttribute(
685731
formMethod: any,
686732
formTarget: any,
687733
name: any,
688-
): void {
734+
): null | FormData {
735+
let formData = null;
689736
if (enableFormActions && typeof formAction === 'function') {
690737
// Function form actions cannot control the form properties
691738
if (__DEV__) {
@@ -714,37 +761,55 @@ function pushFormActionAttribute(
714761
);
715762
}
716763
}
717-
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
718-
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
719-
// manually submitted or if someone calls stopPropagation before React gets the event.
720-
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
721-
// error message but the URL will be logged.
722-
target.push(
723-
attributeSeparator,
724-
stringToChunk('formAction'),
725-
attributeAssign,
726-
actionJavaScriptURL,
727-
attributeEnd,
728-
);
729-
injectFormReplayingRuntime(responseState);
730-
} else {
731-
// Plain form actions support all the properties, so we have to emit them.
732-
if (name !== null) {
733-
pushAttribute(target, 'name', name);
734-
}
735-
if (formAction !== null) {
736-
pushAttribute(target, 'formAction', formAction);
737-
}
738-
if (formEncType !== null) {
739-
pushAttribute(target, 'formEncType', formEncType);
740-
}
741-
if (formMethod !== null) {
742-
pushAttribute(target, 'formMethod', formMethod);
743-
}
744-
if (formTarget !== null) {
745-
pushAttribute(target, 'formTarget', formTarget);
764+
const customAction: CustomFormAction = formAction.$$FORM_ACTION;
765+
if (typeof customAction === 'function') {
766+
// This action has a custom progressive enhancement form that can submit the form
767+
// back to the server if it's invoked before hydration. Such as a Server Action.
768+
const prefix = makeFormFieldPrefix(responseState);
769+
const customFields = customAction(prefix);
770+
name = customFields.name;
771+
formAction = customFields.action || '';
772+
formEncType = customFields.encType;
773+
formMethod = customFields.method;
774+
formTarget = customFields.target;
775+
formData = customFields.data;
776+
} else {
777+
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
778+
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
779+
// manually submitted or if someone calls stopPropagation before React gets the event.
780+
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
781+
// error message but the URL will be logged.
782+
target.push(
783+
attributeSeparator,
784+
stringToChunk('formAction'),
785+
attributeAssign,
786+
actionJavaScriptURL,
787+
attributeEnd,
788+
);
789+
name = null;
790+
formAction = null;
791+
formEncType = null;
792+
formMethod = null;
793+
formTarget = null;
794+
injectFormReplayingRuntime(responseState);
746795
}
747796
}
797+
if (name !== null) {
798+
pushAttribute(target, 'name', name);
799+
}
800+
if (formAction !== null) {
801+
pushAttribute(target, 'formAction', formAction);
802+
}
803+
if (formEncType !== null) {
804+
pushAttribute(target, 'formEncType', formEncType);
805+
}
806+
if (formMethod !== null) {
807+
pushAttribute(target, 'formMethod', formMethod);
808+
}
809+
if (formTarget !== null) {
810+
pushAttribute(target, 'formTarget', formTarget);
811+
}
812+
return formData;
748813
}
749814

750815
function pushAttribute(
@@ -1366,6 +1431,8 @@ function pushStartForm(
13661431
}
13671432
}
13681433

1434+
let formData = null;
1435+
let formActionName = null;
13691436
if (enableFormActions && typeof formAction === 'function') {
13701437
// Function form actions cannot control the form properties
13711438
if (__DEV__) {
@@ -1388,36 +1455,60 @@ function pushStartForm(
13881455
);
13891456
}
13901457
}
1391-
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
1392-
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
1393-
// manually submitted or if someone calls stopPropagation before React gets the event.
1394-
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
1395-
// error message but the URL will be logged.
1396-
target.push(
1397-
attributeSeparator,
1398-
stringToChunk('action'),
1399-
attributeAssign,
1400-
actionJavaScriptURL,
1401-
attributeEnd,
1402-
);
1403-
injectFormReplayingRuntime(responseState);
1404-
} else {
1405-
// Plain form actions support all the properties, so we have to emit them.
1406-
if (formAction !== null) {
1407-
pushAttribute(target, 'action', formAction);
1408-
}
1409-
if (formEncType !== null) {
1410-
pushAttribute(target, 'encType', formEncType);
1411-
}
1412-
if (formMethod !== null) {
1413-
pushAttribute(target, 'method', formMethod);
1414-
}
1415-
if (formTarget !== null) {
1416-
pushAttribute(target, 'target', formTarget);
1458+
const customAction: CustomFormAction = formAction.$$FORM_ACTION;
1459+
if (typeof customAction === 'function') {
1460+
// This action has a custom progressive enhancement form that can submit the form
1461+
// back to the server if it's invoked before hydration. Such as a Server Action.
1462+
const prefix = makeFormFieldPrefix(responseState);
1463+
const customFields = customAction(prefix);
1464+
formAction = customFields.action || '';
1465+
formEncType = customFields.encType;
1466+
formMethod = customFields.method;
1467+
formTarget = customFields.target;
1468+
formData = customFields.data;
1469+
formActionName = customFields.name;
1470+
} else {
1471+
// Set a javascript URL that doesn't do anything. We don't expect this to be invoked
1472+
// because we'll preventDefault in the Fizz runtime, but it can happen if a form is
1473+
// manually submitted or if someone calls stopPropagation before React gets the event.
1474+
// If CSP is used to block javascript: URLs that's fine too. It just won't show this
1475+
// error message but the URL will be logged.
1476+
target.push(
1477+
attributeSeparator,
1478+
stringToChunk('action'),
1479+
attributeAssign,
1480+
actionJavaScriptURL,
1481+
attributeEnd,
1482+
);
1483+
formAction = null;
1484+
formEncType = null;
1485+
formMethod = null;
1486+
formTarget = null;
1487+
injectFormReplayingRuntime(responseState);
14171488
}
14181489
}
1490+
if (formAction !== null) {
1491+
pushAttribute(target, 'action', formAction);
1492+
}
1493+
if (formEncType !== null) {
1494+
pushAttribute(target, 'encType', formEncType);
1495+
}
1496+
if (formMethod !== null) {
1497+
pushAttribute(target, 'method', formMethod);
1498+
}
1499+
if (formTarget !== null) {
1500+
pushAttribute(target, 'target', formTarget);
1501+
}
14191502

14201503
target.push(endOfStartTag);
1504+
1505+
if (formActionName !== null) {
1506+
target.push(startHiddenInputChunk);
1507+
pushStringAttribute(target, 'name', formActionName);
1508+
target.push(endOfStartTagSelfClosing);
1509+
pushAdditionalFormFields(target, formData);
1510+
}
1511+
14211512
pushInnerHTML(target, innerHTML, children);
14221513
if (typeof children === 'string') {
14231514
// Special case children as a string to avoid the unnecessary comment.
@@ -1510,7 +1601,7 @@ function pushInput(
15101601
}
15111602
}
15121603

1513-
pushFormActionAttribute(
1604+
const formData = pushFormActionAttribute(
15141605
target,
15151606
responseState,
15161607
formAction,
@@ -1561,6 +1652,10 @@ function pushInput(
15611652
}
15621653

15631654
target.push(endOfStartTagSelfClosing);
1655+
1656+
// We place any additional hidden form fields after the input.
1657+
pushAdditionalFormFields(target, formData);
1658+
15641659
return null;
15651660
}
15661661

@@ -1628,7 +1723,7 @@ function pushStartButton(
16281723
}
16291724
}
16301725

1631-
pushFormActionAttribute(
1726+
const formData = pushFormActionAttribute(
16321727
target,
16331728
responseState,
16341729
formAction,
@@ -1639,13 +1734,18 @@ function pushStartButton(
16391734
);
16401735

16411736
target.push(endOfStartTag);
1737+
1738+
// We place any additional hidden form fields we need to include inside the button itself.
1739+
pushAdditionalFormFields(target, formData);
1740+
16421741
pushInnerHTML(target, innerHTML, children);
16431742
if (typeof children === 'string') {
16441743
// Special case children as a string to avoid the unnecessary comment.
16451744
// TODO: Remove this special case after the general optimization is in place.
16461745
target.push(stringToChunk(encodeHTMLTextNode(children)));
16471746
return null;
16481747
}
1748+
16491749
return children;
16501750
}
16511751

0 commit comments

Comments
 (0)