From 804dddb861559343662cdca663e75037420e00c8 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 2 May 2024 16:48:14 +0200 Subject: [PATCH 1/2] Fallback to client replaying actions if we're trying to serialize a Blob 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
nodes in these tests. I had to skip testing things like the content of the blob. --- .../src/server/ReactFizzConfigDOM.js | 30 ++++++-- .../src/__tests__/ReactFlightDOMForm-test.js | 73 +++++++++++++++++++ scripts/error-codes/codes.json | 2 +- 3 files changed, 97 insertions(+), 8 deletions(-) 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..a039a80823be7 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -962,4 +962,77 @@ 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); + if (errorMsg) { + blob = errorMsg; + } + return ; + } + + 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( + , + webpackMap, + ); + const postbackResponse = ReactServerDOMClient.createFromReadableStream( + postbackRscStream, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const postbackSsrStream = await ReactDOMServer.renderToReadableStream( + postbackResponse, + {formState: 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); + }); }); 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.", From 8dc0498e76125cb200b9f7b0c7e7a869ee9fc9b0 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 3 May 2024 17:09:13 -0400 Subject: [PATCH 2/2] Polyfill Blob methods --- .../src/__tests__/ReactFlightDOMForm-test.js | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) 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 a039a80823be7..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(); @@ -975,10 +992,12 @@ describe('ReactFlightDOMForm', () => { function Form({action}) { const [errorMsg, dispatch] = useActionState(action, null); + let text; if (errorMsg) { blob = errorMsg; + text = React.use(blob.text()); } - return ; + return {text}; } const FormRef = await clientExports(Form); @@ -1008,21 +1027,19 @@ describe('ReactFlightDOMForm', () => { container.innerHTML = ''; const postbackRscStream = ReactServerDOMServer.renderToReadableStream( - , + {formState, root: }, webpackMap, ); - const postbackResponse = ReactServerDOMClient.createFromReadableStream( - postbackRscStream, - { + const postbackResponse = + await ReactServerDOMClient.createFromReadableStream(postbackRscStream, { ssrManifest: { moduleMap: null, moduleLoading: null, }, - }, - ); + }); const postbackSsrStream = await ReactDOMServer.renderToReadableStream( - postbackResponse, - {formState: formState}, + postbackResponse.root, + {formState: postbackResponse.formState}, ); await readIntoContainer(postbackSsrStream); } @@ -1034,5 +1051,8 @@ describe('ReactFlightDOMForm', () => { expect(blob instanceof Blob).toBe(true); expect(blob.size).toBe(2); + + const form2 = container.getElementsByTagName('form')[0]; + expect(form2.textContent).toBe('hi'); }); });