Skip to content

Commit 6bac4f2

Browse files
authored
[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.
1 parent 826bf4e commit 6bac4f2

File tree

3 files changed

+117
-8
lines changed

3 files changed

+117
-8
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

+23-7
Original file line numberDiff line numberDiff line change
@@ -1019,12 +1019,7 @@ function pushAdditionalFormField(
10191019
): void {
10201020
const target: Array<Chunk | PrecomputedChunk> = this;
10211021
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);
10281023
pushStringAttribute(target, 'name', key);
10291024
pushStringAttribute(target, 'value', value);
10301025
target.push(endOfStartTagSelfClosing);
@@ -1040,6 +1035,23 @@ function pushAdditionalFormFields(
10401035
}
10411036
}
10421037

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+
10431055
function getCustomFormFields(
10441056
resumableState: ResumableState,
10451057
formAction: any,
@@ -1048,7 +1060,11 @@ function getCustomFormFields(
10481060
if (typeof customAction === 'function') {
10491061
const prefix = makeFormFieldPrefix(resumableState);
10501062
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;
10521068
} catch (x) {
10531069
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
10541070
// Rethrow suspense.

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js

+93
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ global.ReadableStream =
1717
global.TextEncoder = require('util').TextEncoder;
1818
global.TextDecoder = require('util').TextDecoder;
1919

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+
2037
// Don't wait before processing work on the server.
2138
// TODO: we can replace this with FlightServer.act().
2239
global.setTimeout = cb => cb();
@@ -962,4 +979,80 @@ describe('ReactFlightDOMForm', () => {
962979
expect(form2.textContent).toBe('error message');
963980
expect(form2.firstChild.tagName).toBe('DIV');
964981
});
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+
});
9651058
});

scripts/error-codes/codes.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@
465465
"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.",
466466
"478": "Thenable should have already resolved. This is a bug in React.",
467467
"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.",
469469
"481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.",
470470
"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.",
471471
"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.",

0 commit comments

Comments
 (0)