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