From 5fcfd71638401958d437e74f8cf384c4c1ba4665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 3 May 2024 22:29:09 +0200 Subject: [PATCH] Use undici polyfill for tests in old Node versions (#28887) We currently don't test FormData / File dependent features in CI because we use an old Node.js version in CI. We should probably upgrade to 18 since that's really the minimum version that supports all the features out of the box. JSDOM is not a faithful/compatible implementation of these APIs. The recommended way to use Flight together with FormData/Blob/File in older Node.js versions, is to polyfill using the `undici` library. However, even in these versions the Blob implementation isn't quite faithful so the Reply client needs a slight tweak for multi-byte typed arrays. --- package.json | 1 + .../src/ReactFlightReplyClient.js | 20 ++++- .../src/__tests__/ReactFlight-test.js | 62 ++++++++------- .../src/__tests__/ReactFlightDOMEdge-test.js | 74 +++++++++--------- .../__tests__/ReactFlightDOMReplyEdge-test.js | 76 +++++++++---------- yarn.lock | 12 +++ 6 files changed, 132 insertions(+), 113 deletions(-) diff --git a/package.json b/package.json index 7e9b35f00bee..00a3a5e67df6 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "through2": "^3.0.1", "tmp": "^0.1.0", "typescript": "^3.7.5", + "undici": "^5.28.4", "web-streams-polyfill": "^3.1.1", "yargs": "^15.3.1" }, diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index eb562dd7b23c..b54f6e4edb0f 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -187,9 +187,17 @@ export function processReply( function serializeTypedArray( tag: string, - typedArray: ArrayBuffer | $ArrayBufferView, + typedArray: $ArrayBufferView, ): string { - const blob = new Blob([typedArray]); + const blob = new Blob([ + // We should be able to pass the buffer straight through but Node < 18 treat + // multi-byte array blobs differently so we first convert it to single-byte. + new Uint8Array( + typedArray.buffer, + typedArray.byteOffset, + typedArray.byteLength, + ), + ]); const blobId = nextPartId++; if (formData === null) { formData = new FormData(); @@ -392,7 +400,13 @@ export function processReply( if (enableBinaryFlight) { if (value instanceof ArrayBuffer) { - return serializeTypedArray('A', value); + const blob = new Blob([value]); + const blobId = nextPartId++; + if (formData === null) { + formData = new FormData(); + } + formData.append(formFieldPrefix + blobId, blob); + return '$' + 'A' + blobId.toString(16); } if (value instanceof Int8Array) { // char diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 7071ef508154..a2e4d7af2953 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -10,6 +10,14 @@ 'use strict'; +if (typeof Blob === 'undefined') { + global.Blob = require('buffer').Blob; +} +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('undici').File; + global.FormData = require('undici').FormData; +} + function normalizeCodeLocInfo(str) { return ( str && @@ -513,39 +521,37 @@ describe('ReactFlight', () => { `); }); - if (typeof FormData !== 'undefined') { - it('can transport FormData (no blobs)', async () => { - function ComponentClient({prop}) { - return ` - formData: ${prop instanceof FormData} - hi: ${prop.get('hi')} - multiple: ${prop.getAll('multiple')} - content: ${JSON.stringify(Array.from(prop))} - `; - } - const Component = clientReference(ComponentClient); - - const formData = new FormData(); - formData.append('hi', 'world'); - formData.append('multiple', 1); - formData.append('multiple', 2); + it('can transport FormData (no blobs)', async () => { + function ComponentClient({prop}) { + return ` + formData: ${prop instanceof FormData} + hi: ${prop.get('hi')} + multiple: ${prop.getAll('multiple')} + content: ${JSON.stringify(Array.from(prop))} + `; + } + const Component = clientReference(ComponentClient); - const model = ; + const formData = new FormData(); + formData.append('hi', 'world'); + formData.append('multiple', 1); + formData.append('multiple', 2); - const transport = ReactNoopFlightServer.render(model); + const model = ; - await act(async () => { - ReactNoop.render(await ReactNoopFlightClient.read(transport)); - }); + const transport = ReactNoopFlightServer.render(model); - expect(ReactNoop).toMatchRenderedOutput(` - formData: true - hi: world - multiple: 1,2 - content: [["hi","world"],["multiple","1"],["multiple","2"]] - `); + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); }); - } + + expect(ReactNoop).toMatchRenderedOutput(` + formData: true + hi: world + multiple: 1,2 + content: [["hi","world"],["multiple","1"],["multiple","2"]] + `); + }); it('can transport cyclic objects', async () => { function ComponentClient({prop}) { diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 6bbbf9d82f69..8ea9c1907eb6 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -15,11 +15,10 @@ global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -if (typeof Blob === 'undefined') { - global.Blob = require('buffer').Blob; -} -if (typeof File === 'undefined') { - global.File = require('buffer').File; +global.Blob = require('buffer').Blob; +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('buffer').File || require('undici').File; + global.FormData = require('undici').FormData; } // Patch for Edge environments for global scope global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage; @@ -383,45 +382,40 @@ describe('ReactFlightDOMEdge', () => { expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer()); }); - if (typeof FormData !== 'undefined' && typeof File !== 'undefined') { - // @gate enableBinaryFlight - it('can transport FormData (blobs)', async () => { - const bytes = new Uint8Array([ - 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, - ]); - const blob = new Blob([bytes, bytes], { - type: 'application/x-test', - }); + // @gate enableBinaryFlight + it('can transport FormData (blobs)', async () => { + const bytes = new Uint8Array([ + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, + ]); + const blob = new Blob([bytes, bytes], { + type: 'application/x-test', + }); - const formData = new FormData(); - formData.append('hi', 'world'); - formData.append('file', blob, 'filename.test'); + const formData = new FormData(); + formData.append('hi', 'world'); + formData.append('file', blob, 'filename.test'); - expect(formData.get('file') instanceof File).toBe(true); - expect(formData.get('file').name).toBe('filename.test'); + expect(formData.get('file') instanceof File).toBe(true); + expect(formData.get('file').name).toBe('filename.test'); - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream(formData), - ); - const result = await ReactServerDOMClient.createFromReadableStream( - stream, - { - ssrManifest: { - moduleMap: null, - moduleLoading: null, - }, - }, - ); - - expect(result instanceof FormData).toBe(true); - expect(result.get('hi')).toBe('world'); - const resultBlob = result.get('file'); - expect(resultBlob instanceof Blob).toBe(true); - expect(resultBlob.name).toBe('blob'); // We should not pass through the file name for security. - expect(resultBlob.size).toBe(bytes.length * 2); - expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + const stream = passThrough( + ReactServerDOMServer.renderToReadableStream(formData), + ); + const result = await ReactServerDOMClient.createFromReadableStream(stream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, }); - } + + expect(result instanceof FormData).toBe(true); + expect(result.get('hi')).toBe('world'); + const resultBlob = result.get('file'); + expect(resultBlob instanceof Blob).toBe(true); + expect(resultBlob.name).toBe('blob'); // We should not pass through the file name for security. + expect(resultBlob.size).toBe(bytes.length * 2); + expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + }); it('can pass an async import that resolves later to an outline object like a Map', async () => { let resolve; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js index 00a53a590c5e..ab0d54c0bc0f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js @@ -16,11 +16,10 @@ global.ReadableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -if (typeof Blob === 'undefined') { - global.Blob = require('buffer').Blob; -} -if (typeof File === 'undefined') { - global.File = require('buffer').File; +global.Blob = require('buffer').Blob; +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('buffer').File || require('undici').File; + global.FormData = require('undici').FormData; } // let serverExports; @@ -44,13 +43,6 @@ describe('ReactFlightDOMReplyEdge', () => { ReactServerDOMClient = require('react-server-dom-webpack/client.edge'); }); - if (typeof FormData === 'undefined') { - // We can't test if we don't have a native FormData implementation because the JSDOM one - // is missing the arrayBuffer() method. - it('cannot test', () => {}); - return; - } - it('can encode a reply', async () => { const body = await ReactServerDOMClient.encodeReply({some: 'object'}); const decoded = await ReactServerDOMServer.decodeReply( @@ -89,6 +81,8 @@ describe('ReactFlightDOMReplyEdge', () => { ); expect(result).toEqual(buffers); + // Array buffers can't use the toEqual helper. + expect(new Uint8Array(result[0])).toEqual(new Uint8Array(buffers[0])); }); // @gate enableBinaryFlight @@ -109,35 +103,33 @@ describe('ReactFlightDOMReplyEdge', () => { expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer()); }); - if (typeof FormData !== 'undefined' && typeof File !== 'undefined') { - it('can transport FormData (blobs)', async () => { - const bytes = new Uint8Array([ - 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, - ]); - const blob = new Blob([bytes, bytes], { - type: 'application/x-test', - }); - - const formData = new FormData(); - formData.append('hi', 'world'); - formData.append('file', blob, 'filename.test'); - - expect(formData.get('file') instanceof File).toBe(true); - expect(formData.get('file').name).toBe('filename.test'); - - const body = await ReactServerDOMClient.encodeReply(formData); - const result = await ReactServerDOMServer.decodeReply( - body, - webpackServerMap, - ); - - expect(result instanceof FormData).toBe(true); - expect(result.get('hi')).toBe('world'); - const resultBlob = result.get('file'); - expect(resultBlob instanceof Blob).toBe(true); - expect(resultBlob.name).toBe('filename.test'); // In this direction we allow file name to pass through but not other direction. - expect(resultBlob.size).toBe(bytes.length * 2); - expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + it('can transport FormData (blobs)', async () => { + const bytes = new Uint8Array([ + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, + ]); + const blob = new Blob([bytes, bytes], { + type: 'application/x-test', }); - } + + const formData = new FormData(); + formData.append('hi', 'world'); + formData.append('file', blob, 'filename.test'); + + expect(formData.get('file') instanceof File).toBe(true); + expect(formData.get('file').name).toBe('filename.test'); + + const body = await ReactServerDOMClient.encodeReply(formData); + const result = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + ); + + expect(result instanceof FormData).toBe(true); + expect(result.get('hi')).toBe('world'); + const resultBlob = result.get('file'); + expect(resultBlob instanceof Blob).toBe(true); + expect(resultBlob.name).toBe('filename.test'); // In this direction we allow file name to pass through but not other direction. + expect(resultBlob.size).toBe(bytes.length * 2); + expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + }); }); diff --git a/yarn.lock b/yarn.lock index b68244928f09..e56d08d371ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2182,6 +2182,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.0.0.tgz#1a9e4b4c96d8c7886e0110ed310a0135144a1691" integrity sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ== +"@fastify/busboy@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" + integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== + "@gitbeaker/core@^21.7.0": version "21.7.0" resolved "https://registry.yarnpkg.com/@gitbeaker/core/-/core-21.7.0.tgz#fcf7a12915d39f416e3f316d0a447a814179b8e5" @@ -15762,6 +15767,13 @@ unc-path-regex@^0.1.0, unc-path-regex@^0.1.2: resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= +undici@^5.28.4: + version "5.28.4" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068" + integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g== + dependencies: + "@fastify/busboy" "^2.0.0" + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"