diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 4c4bf3029112d..5712e935a6e75 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -1019,12 +1019,7 @@ function pushAdditionalFormField( ): void { const target: Array = this; target.push(startHiddenInputChunk); - if (typeof value !== 'string') { - throw new Error( - '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.', - ); - } + validateAdditionalFormField(value, key); pushStringAttribute(target, 'name', key); pushStringAttribute(target, 'value', value); target.push(endOfStartTagSelfClosing); @@ -1040,6 +1035,23 @@ function pushAdditionalFormFields( } } +function validateAdditionalFormField(value: string | File, key: string): void { + if (typeof value !== 'string') { + throw new Error( + 'File/Blob fields are not yet supported in progressive forms. ' + + 'Will fallback to client hydration.', + ); + } +} + +function validateAdditionalFormFields(formData: void | null | FormData) { + if (formData != null) { + // $FlowFixMe[prop-missing]: FormData has forEach. + formData.forEach(validateAdditionalFormField); + } + return formData; +} + function getCustomFormFields( resumableState: ResumableState, formAction: any, @@ -1048,7 +1060,11 @@ function getCustomFormFields( if (typeof customAction === 'function') { const prefix = makeFormFieldPrefix(resumableState); try { - return formAction.$$FORM_ACTION(prefix); + const customFields = formAction.$$FORM_ACTION(prefix); + if (customFields) { + validateAdditionalFormFields(customFields.data); + } + return customFields; } catch (x) { if (typeof x === 'object' && x !== null && typeof x.then === 'function') { // Rethrow suspense. diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js index e90a361806816..b86e3447540b2 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -17,6 +17,23 @@ global.ReadableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; +// Polyfill stream methods on JSDOM. +global.Blob.prototype.stream = function () { + const impl = Object.getOwnPropertySymbols(this)[0]; + const buffer = this[impl]._buffer; + return new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(buffer)); + c.close(); + }, + }); +}; + +global.Blob.prototype.text = async function () { + const impl = Object.getOwnPropertySymbols(this)[0]; + return this[impl]._buffer.toString('utf8'); +}; + // Don't wait before processing work on the server. // TODO: we can replace this with FlightServer.act(). global.setTimeout = cb => cb(); @@ -962,4 +979,80 @@ describe('ReactFlightDOMForm', () => { expect(form2.textContent).toBe('error message'); expect(form2.firstChild.tagName).toBe('DIV'); }); + + // @gate enableAsyncActions && enableBinaryFlight + it('useActionState can return binary state during MPA form submission', async () => { + const serverAction = serverExports( + async function action(prevState, formData) { + return new Blob([new Uint8Array([104, 105])]); + }, + ); + + let blob; + + function Form({action}) { + const [errorMsg, dispatch] = useActionState(action, null); + let text; + if (errorMsg) { + blob = errorMsg; + text = React.use(blob.text()); + } + return
{text}
; + } + + const FormRef = await clientExports(Form); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form1 = container.getElementsByTagName('form')[0]; + expect(form1.textContent).toBe(''); + + async function submitTheForm() { + const form = container.getElementsByTagName('form')[0]; + const {formState} = await submit(form); + + // Simulate an MPA form submission by resetting the container and + // rendering again. + container.innerHTML = ''; + + const postbackRscStream = ReactServerDOMServer.renderToReadableStream( + {formState, root: }, + webpackMap, + ); + const postbackResponse = + await ReactServerDOMClient.createFromReadableStream(postbackRscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const postbackSsrStream = await ReactDOMServer.renderToReadableStream( + postbackResponse.root, + {formState: postbackResponse.formState}, + ); + await readIntoContainer(postbackSsrStream); + } + + await expect(submitTheForm).toErrorDev( + 'Warning: Failed to serialize an action for progressive enhancement:\n' + + 'Error: File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration.', + ); + + expect(blob instanceof Blob).toBe(true); + expect(blob.size).toBe(2); + + const form2 = container.getElementsByTagName('form')[0]; + expect(form2.textContent).toBe('hi'); + }); }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 858c0519854b7..4c1735a79cf96 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -465,7 +465,7 @@ "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.", "478": "Thenable should have already resolved. This is a bug in React.", "479": "Cannot update optimistic state while rendering.", - "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.", + "480": "File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration.", "481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.", "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.", "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.",