Skip to content

Commit 559e83a

Browse files
authored
[Fizz] Allow an action provide a custom set of props to use for progressive enhancement (#26749)
Stacked on top of #26735. This allows a framework to add a `$$FORM_ACTION` property to a function. This lets the framework return a set of props to use in place of the function but only during SSR. Effectively, this lets you implement progressive enhancement of form actions using some other way instead of relying on the replay feature. This will be used by RSC on Server References automatically by convention in a follow up, but this mechanism can also be used by other frameworks/libraries.
1 parent 67f4fb0 commit 559e83a

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 = formAction.$$FORM_ACTION(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 = formAction.$$FORM_ACTION(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)