@samitouri / QOS-React / commits / 559e83aebb

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

Sebastian Markbåge committed May 1, 2023 at 16:01 UTC 559e83aebb2026035d47aa0ebf842f78d4cd6757
5 files changed +302 -75
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+7 -7
@@ -2791,7 +2791,6 @@ function diffHydratedGenericElement(
2791 case 'formAction':
2792 if (enableFormActions) {
2793 const serverValue = domElement.getAttribute(propKey);
2794 - const hasFormActionURL = serverValue === EXPECTED_FORM_ACTION_URL;
2794 if (typeof value === 'function') {
2795 extraAttributes.delete(propKey.toLowerCase());
2796 // The server can set these extra properties to implement actions.
@@ -2806,13 +2805,14 @@ function diffHydratedGenericElement(
2805 extraAttributes.delete('method');
2806 extraAttributes.delete('target');
2807 }
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.
2814 continue;
2815 - } else if (hasFormActionURL) {
2815 + } else if (serverValue === EXPECTED_FORM_ACTION_URL) {
2816 extraAttributes.delete(propKey.toLowerCase());
2817 warnForPropDifference(propKey, 'function', value);
2818 continue;
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+1 -1
@@ -1445,7 +1445,7 @@ export function shouldDeleteUnhydratedTailInstances(
1445 return (
1446 (enableHostSingletons ||
1447 (parentType !== 'head' && parentType !== 'body')) &&
1448 - (!enableFormActions || parentType !== 'form')
1448 + (!enableFormActions || (parentType !== 'form' && parentType !== 'button'))
1449 );
1450 }
1451
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+158 -58
@@ -668,6 +668,22 @@ function pushStringAttribute(
668 }
669 }
670
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 +
687 // Since this will likely be repeated a lot in the HTML, we use a more concise message
688 // than on the client and hopefully it's googleable.
689 const actionJavaScriptURL = stringToPrecomputedChunk(
@@ -677,6 +693,36 @@ const actionJavaScriptURL = stringToPrecomputedChunk(
693 ),
694 );
695
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 +
726 function pushFormActionAttribute(
727 target: Array<Chunk | PrecomputedChunk>,
728 responseState: ResponseState,
@@ -685,7 +731,8 @@ function pushFormActionAttribute(
731 formMethod: any,
732 formTarget: any,
733 name: any,
688 -): void {
734 +): null | FormData {
735 + let formData = null;
736 if (enableFormActions && typeof formAction === 'function') {
737 // Function form actions cannot control the form properties
738 if (__DEV__) {
@@ -714,37 +761,55 @@ function pushFormActionAttribute(
761 );
762 }
763 }
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);
795 }
796 }
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;
813 }
814
815 function pushAttribute(
@@ -1366,6 +1431,8 @@ function pushStartForm(
1431 }
1432 }
1433
1434 + let formData = null;
1435 + let formActionName = null;
1436 if (enableFormActions && typeof formAction === 'function') {
1437 // Function form actions cannot control the form properties
1438 if (__DEV__) {
@@ -1388,36 +1455,60 @@ function pushStartForm(
1455 );
1456 }
1457 }
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);
1488 }
1489 }
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 + }
1502
1503 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 +
1512 pushInnerHTML(target, innerHTML, children);
1513 if (typeof children === 'string') {
1514 // Special case children as a string to avoid the unnecessary comment.
@@ -1510,7 +1601,7 @@ function pushInput(
1601 }
1602 }
1603
1513 - pushFormActionAttribute(
1604 + const formData = pushFormActionAttribute(
1605 target,
1606 responseState,
1607 formAction,
@@ -1561,6 +1652,10 @@ function pushInput(
1652 }
1653
1654 target.push(endOfStartTagSelfClosing);
1655 +
1656 + // We place any additional hidden form fields after the input.
1657 + pushAdditionalFormFields(target, formData);
1658 +
1659 return null;
1660 }
1661
@@ -1628,7 +1723,7 @@ function pushStartButton(
1723 }
1724 }
1725
1631 - pushFormActionAttribute(
1726 + const formData = pushFormActionAttribute(
1727 target,
1728 responseState,
1729 formAction,
@@ -1639,6 +1734,10 @@ function pushStartButton(
1734 );
1735
1736 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 +
1741 pushInnerHTML(target, innerHTML, children);
1742 if (typeof children === 'string') {
1743 // Special case children as a string to avoid the unnecessary comment.
@@ -1646,6 +1745,7 @@ function pushStartButton(
1745 target.push(stringToChunk(encodeHTMLTextNode(children)));
1746 return null;
1747 }
1748 +
1749 return children;
1750 }
1751
packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
+134 -8
@@ -183,7 +183,7 @@ describe('ReactDOMFizzForm', () => {
183 });
184
185 // @gate enableFormActions || !__DEV__
186 - it('should warn when passing a string during SSR and function during hydration', async () => {
186 + it('should ideally warn when passing a string during SSR and function during hydration', async () => {
187 function action(formData) {}
188 function App({isClient}) {
189 return (
@@ -195,13 +195,10 @@ describe('ReactDOMFizzForm', () => {
195
196 const stream = await ReactDOMServer.renderToReadableStream(<App />);
197 await readIntoContainer(stream);
198 - await expect(async () => {
199 - await act(async () => {
200 - ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
201 - });
202 - }).toErrorDev(
203 - 'Prop `action` did not match. Server: "action" Client: "function action(formData) {}"',
204 - );
198 + // This should ideally warn because only the client provides a function that doesn't line up.
199 + await act(async () => {
200 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
201 + });
202 });
203
204 // @gate enableFormActions || !__DEV__
@@ -472,4 +469,133 @@ describe('ReactDOMFizzForm', () => {
469 });
470 expect(container.textContent).toBe('hi');
471 });
472 +
473 + // @gate enableFormActions
474 + it('can provide a custom action on the server for actions', async () => {
475 + const ref = React.createRef();
476 + let foo;
477 +
478 + function action(formData) {
479 + foo = formData.get('foo');
480 + }
481 + action.$$FORM_ACTION = function (identifierPrefix) {
482 + const extraFields = new FormData();
483 + extraFields.append(identifierPrefix + 'hello', 'world');
484 + return {
485 + action: this.name,
486 + name: identifierPrefix,
487 + method: 'POST',
488 + encType: 'multipart/form-data',
489 + target: 'self',
490 + data: extraFields,
491 + };
492 + };
493 + function App() {
494 + return (
495 + <form action={action} ref={ref} method={null}>
496 + <input type="text" name="foo" defaultValue="bar" />
497 + </form>
498 + );
499 + }
500 +
501 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
502 + await readIntoContainer(stream);
503 +
504 + const form = container.firstChild;
505 + expect(form.getAttribute('action')).toBe('action');
506 + expect(form.getAttribute('method')).toBe('POST');
507 + expect(form.getAttribute('enctype')).toBe('multipart/form-data');
508 + expect(form.getAttribute('target')).toBe('self');
509 + const formActionName = form.firstChild.getAttribute('name');
510 + expect(
511 + container
512 + .querySelector('input[name="' + formActionName + 'hello"]')
513 + .getAttribute('value'),
514 + ).toBe('world');
515 +
516 + await act(async () => {
517 + ReactDOMClient.hydrateRoot(container, <App />);
518 + });
519 +
520 + submit(ref.current);
521 +
522 + expect(foo).toBe('bar');
523 + });
524 +
525 + // @gate enableFormActions
526 + it('can provide a custom action on buttons the server for actions', async () => {
527 + const inputRef = React.createRef();
528 + const buttonRef = React.createRef();
529 + let foo;
530 +
531 + function action(formData) {
532 + foo = formData.get('foo');
533 + }
534 + action.$$FORM_ACTION = function (identifierPrefix) {
535 + const extraFields = new FormData();
536 + extraFields.append(identifierPrefix + 'hello', 'world');
537 + return {
538 + action: this.name,
539 + name: identifierPrefix,
540 + method: 'POST',
541 + encType: 'multipart/form-data',
542 + target: 'self',
543 + data: extraFields,
544 + };
545 + };
546 + function App() {
547 + return (
548 + <form>
549 + <input type="hidden" name="foo" value="bar" />
550 + <input
551 + type="submit"
552 + formAction={action}
553 + method={null}
554 + ref={inputRef}
555 + />
556 + <button formAction={action} ref={buttonRef} target={null} />
557 + </form>
558 + );
559 + }
560 +
561 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
562 + await readIntoContainer(stream);
563 +
564 + const input = container.getElementsByTagName('input')[1];
565 + const button = container.getElementsByTagName('button')[0];
566 + expect(input.getAttribute('formaction')).toBe('action');
567 + expect(input.getAttribute('formmethod')).toBe('POST');
568 + expect(input.getAttribute('formenctype')).toBe('multipart/form-data');
569 + expect(input.getAttribute('formtarget')).toBe('self');
570 + expect(button.getAttribute('formaction')).toBe('action');
571 + expect(button.getAttribute('formmethod')).toBe('POST');
572 + expect(button.getAttribute('formenctype')).toBe('multipart/form-data');
573 + expect(button.getAttribute('formtarget')).toBe('self');
574 + const inputName = input.getAttribute('name');
575 + const buttonName = button.getAttribute('name');
576 + expect(
577 + container
578 + .querySelector('input[name="' + inputName + 'hello"]')
579 + .getAttribute('value'),
580 + ).toBe('world');
581 + expect(
582 + container
583 + .querySelector('input[name="' + buttonName + 'hello"]')
584 + .getAttribute('value'),
585 + ).toBe('world');
586 +
587 + await act(async () => {
588 + ReactDOMClient.hydrateRoot(container, <App />);
589 + });
590 +
591 + submit(inputRef.current);
592 +
593 + expect(foo).toBe('bar');
594 +
595 + foo = null;
596 +
597 + submit(buttonRef.current);
598 +
599 + expect(foo).toBe('bar');
600 + });
601 });
scripts/error-codes/codes.json
+2 -1
@@ -464,5 +464,6 @@
464 "476": "Expected the form instance to be a HostComponent. This is a bug in React.",
465 "477": "React Internal Error: processHintChunk is not implemented for Native-Relay. The fact that this method was called means there is a bug in React.",
466 "478": "Thenable should have already resolved. This is a bug in React.",
467 - "479": "Cannot update optimistic state while rendering."
467 + "479": "Cannot update optimistic state while rendering.",
468 + "480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action."
469 }