@samitouri / QOS-React / commits / 6bac4f2f31

[Fizz] Fallback to client replaying actions if we're trying to serialize a Blob (#28987)

This follows the same principle as in #28611. We cannot serialize Blobs of a form data into HTML because you can't initialize a file input to some value. However the serialization of state in an Action can contain blobs. In this case we do error but outside the try/catch that recovers to error to client replaying instead of MPA mode. This errors earlier to ensure that this works. Testing this is a bit annoying because JSDOM doesn't have any of the Blob methods but the Blob needs to be compatible with FormData and the FormData needs to be compatible with `<form>` nodes in these tests. So I polyfilled those in JSDOM with some hacks. A possible future enhancement would be to encode these blobs in a base64 mode instead and have some way to receive them on the server. It's just a matter of layering this. I think the RSC layer's `FORM_DATA` implementation can pass some flag to encode as base64 and then have decodeAction include some way to parse them. That way this case would work in MPA mode too.

Sebastian Markbåge committed May 7, 2024 at 21:53 UTC 6bac4f2f31378cd58dffe6181e00639366a6081a
3 files changed +117 -8
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+23 -7
@@ -1019,12 +1019,7 @@ function pushAdditionalFormField(
1019 ): void {
1020 const target: Array<Chunk | PrecomputedChunk> = this;
1021 target.push(startHiddenInputChunk);
1022 - if (typeof value !== 'string') {
1023 - throw new Error(
1024 - 'File/Blob fields are not yet supported in progressive forms. ' +
1025 - 'It probably means you are closing over binary data or FormData in a Server Action.',
1026 - );
1027 - }
1022 + validateAdditionalFormField(value, key);
1023 pushStringAttribute(target, 'name', key);
1024 pushStringAttribute(target, 'value', value);
1025 target.push(endOfStartTagSelfClosing);
@@ -1040,6 +1035,23 @@ function pushAdditionalFormFields(
1035 }
1036 }
1037
1038 +function validateAdditionalFormField(value: string | File, key: string): void {
1039 + if (typeof value !== 'string') {
1040 + throw new Error(
1041 + 'File/Blob fields are not yet supported in progressive forms. ' +
1042 + 'Will fallback to client hydration.',
1043 + );
1044 + }
1045 +}
1046 +
1047 +function validateAdditionalFormFields(formData: void | null | FormData) {
1048 + if (formData != null) {
1049 + // $FlowFixMe[prop-missing]: FormData has forEach.
1050 + formData.forEach(validateAdditionalFormField);
1051 + }
1052 + return formData;
1053 +}
1054 +
1055 function getCustomFormFields(
1056 resumableState: ResumableState,
1057 formAction: any,
@@ -1048,7 +1060,11 @@ function getCustomFormFields(
1060 if (typeof customAction === 'function') {
1061 const prefix = makeFormFieldPrefix(resumableState);
1062 try {
1051 - return formAction.$$FORM_ACTION(prefix);
1063 + const customFields = formAction.$$FORM_ACTION(prefix);
1064 + if (customFields) {
1065 + validateAdditionalFormFields(customFields.data);
1066 + }
1067 + return customFields;
1068 } catch (x) {
1069 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1070 // Rethrow suspense.
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+93
@@ -17,6 +17,23 @@ global.ReadableStream =
17 global.TextEncoder = require('util').TextEncoder;
18 global.TextDecoder = require('util').TextDecoder;
19
20 +// Polyfill stream methods on JSDOM.
21 +global.Blob.prototype.stream = function () {
22 + const impl = Object.getOwnPropertySymbols(this)[0];
23 + const buffer = this[impl]._buffer;
24 + return new ReadableStream({
25 + start(c) {
26 + c.enqueue(new Uint8Array(buffer));
27 + c.close();
28 + },
29 + });
30 +};
31 +
32 +global.Blob.prototype.text = async function () {
33 + const impl = Object.getOwnPropertySymbols(this)[0];
34 + return this[impl]._buffer.toString('utf8');
35 +};
36 +
37 // Don't wait before processing work on the server.
38 // TODO: we can replace this with FlightServer.act().
39 global.setTimeout = cb => cb();
@@ -962,4 +979,80 @@ describe('ReactFlightDOMForm', () => {
979 expect(form2.textContent).toBe('error message');
980 expect(form2.firstChild.tagName).toBe('DIV');
981 });
982 +
983 + // @gate enableAsyncActions && enableBinaryFlight
984 + it('useActionState can return binary state during MPA form submission', async () => {
985 + const serverAction = serverExports(
986 + async function action(prevState, formData) {
987 + return new Blob([new Uint8Array([104, 105])]);
988 + },
989 + );
990 +
991 + let blob;
992 +
993 + function Form({action}) {
994 + const [errorMsg, dispatch] = useActionState(action, null);
995 + let text;
996 + if (errorMsg) {
997 + blob = errorMsg;
998 + text = React.use(blob.text());
999 + }
1000 + return <form action={dispatch}>{text}</form>;
1001 + }
1002 +
1003 + const FormRef = await clientExports(Form);
1004 +
1005 + const rscStream = ReactServerDOMServer.renderToReadableStream(
1006 + <FormRef action={serverAction} />,
1007 + webpackMap,
1008 + );
1009 + const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
1010 + ssrManifest: {
1011 + moduleMap: null,
1012 + moduleLoading: null,
1013 + },
1014 + });
1015 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
1016 + await readIntoContainer(ssrStream);
1017 +
1018 + const form1 = container.getElementsByTagName('form')[0];
1019 + expect(form1.textContent).toBe('');
1020 +
1021 + async function submitTheForm() {
1022 + const form = container.getElementsByTagName('form')[0];
1023 + const {formState} = await submit(form);
1024 +
1025 + // Simulate an MPA form submission by resetting the container and
1026 + // rendering again.
1027 + container.innerHTML = '';
1028 +
1029 + const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
1030 + {formState, root: <FormRef action={serverAction} />},
1031 + webpackMap,
1032 + );
1033 + const postbackResponse =
1034 + await ReactServerDOMClient.createFromReadableStream(postbackRscStream, {
1035 + ssrManifest: {
1036 + moduleMap: null,
1037 + moduleLoading: null,
1038 + },
1039 + });
1040 + const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
1041 + postbackResponse.root,
1042 + {formState: postbackResponse.formState},
1043 + );
1044 + await readIntoContainer(postbackSsrStream);
1045 + }
1046 +
1047 + await expect(submitTheForm).toErrorDev(
1048 + 'Warning: Failed to serialize an action for progressive enhancement:\n' +
1049 + 'Error: File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration.',
1050 + );
1051 +
1052 + expect(blob instanceof Blob).toBe(true);
1053 + expect(blob.size).toBe(2);
1054 +
1055 + const form2 = container.getElementsByTagName('form')[0];
1056 + expect(form2.textContent).toBe('hi');
1057 + });
1058 });
scripts/error-codes/codes.json
+1 -1
@@ -465,7 +465,7 @@
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.",
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.",
468 + "480": "File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration.",
469 "481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.",
470 "482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
471 "483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",