@samitouri / QOS-React / commits / 5fcfd71638

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.

Sebastian Markbåge committed May 3, 2024 at 22:29 UTC 5fcfd71638401958d437e74f8cf384c4c1ba4665
6 files changed +132 -113
package.json
+1
@@ -97,6 +97,7 @@
97 "through2": "^3.0.1",
98 "tmp": "^0.1.0",
99 "typescript": "^3.7.5",
100 + "undici": "^5.28.4",
101 "web-streams-polyfill": "^3.1.1",
102 "yargs": "^15.3.1"
103 },
packages/react-client/src/ReactFlightReplyClient.js
+17 -3
@@ -187,9 +187,17 @@ export function processReply(
187
188 function serializeTypedArray(
189 tag: string,
190 - typedArray: ArrayBuffer | $ArrayBufferView,
190 + typedArray: $ArrayBufferView,
191 ): string {
192 - const blob = new Blob([typedArray]);
192 + const blob = new Blob([
193 + // We should be able to pass the buffer straight through but Node < 18 treat
194 + // multi-byte array blobs differently so we first convert it to single-byte.
195 + new Uint8Array(
196 + typedArray.buffer,
197 + typedArray.byteOffset,
198 + typedArray.byteLength,
199 + ),
200 + ]);
201 const blobId = nextPartId++;
202 if (formData === null) {
203 formData = new FormData();
@@ -392,7 +400,13 @@ export function processReply(
400
401 if (enableBinaryFlight) {
402 if (value instanceof ArrayBuffer) {
395 - return serializeTypedArray('A', value);
403 + const blob = new Blob([value]);
404 + const blobId = nextPartId++;
405 + if (formData === null) {
406 + formData = new FormData();
407 + }
408 + formData.append(formFieldPrefix + blobId, blob);
409 + return '$' + 'A' + blobId.toString(16);
410 }
411 if (value instanceof Int8Array) {
412 // char
packages/react-client/src/__tests__/ReactFlight-test.js
+34 -28
@@ -10,6 +10,14 @@
10
11 'use strict';
12
13 +if (typeof Blob === 'undefined') {
14 + global.Blob = require('buffer').Blob;
15 +}
16 +if (typeof File === 'undefined' || typeof FormData === 'undefined') {
17 + global.File = require('undici').File;
18 + global.FormData = require('undici').FormData;
19 +}
20 +
21 function normalizeCodeLocInfo(str) {
22 return (
23 str &&
@@ -513,39 +521,37 @@ describe('ReactFlight', () => {
521 `);
522 });
523
516 - if (typeof FormData !== 'undefined') {
517 - it('can transport FormData (no blobs)', async () => {
518 - function ComponentClient({prop}) {
519 - return `
520 - formData: ${prop instanceof FormData}
521 - hi: ${prop.get('hi')}
522 - multiple: ${prop.getAll('multiple')}
523 - content: ${JSON.stringify(Array.from(prop))}
524 - `;
525 - }
526 - const Component = clientReference(ComponentClient);
527 -
528 - const formData = new FormData();
529 - formData.append('hi', 'world');
530 - formData.append('multiple', 1);
531 - formData.append('multiple', 2);
524 + it('can transport FormData (no blobs)', async () => {
525 + function ComponentClient({prop}) {
526 + return `
527 + formData: ${prop instanceof FormData}
528 + hi: ${prop.get('hi')}
529 + multiple: ${prop.getAll('multiple')}
530 + content: ${JSON.stringify(Array.from(prop))}
531 + `;
532 + }
533 + const Component = clientReference(ComponentClient);
534
533 - const model = <Component prop={formData} />;
535 + const formData = new FormData();
536 + formData.append('hi', 'world');
537 + formData.append('multiple', 1);
538 + formData.append('multiple', 2);
539
535 - const transport = ReactNoopFlightServer.render(model);
540 + const model = <Component prop={formData} />;
541
537 - await act(async () => {
538 - ReactNoop.render(await ReactNoopFlightClient.read(transport));
539 - });
542 + const transport = ReactNoopFlightServer.render(model);
543
541 - expect(ReactNoop).toMatchRenderedOutput(`
542 - formData: true
543 - hi: world
544 - multiple: 1,2
545 - content: [["hi","world"],["multiple","1"],["multiple","2"]]
546 - `);
544 + await act(async () => {
545 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
546 });
548 - }
547 +
548 + expect(ReactNoop).toMatchRenderedOutput(`
549 + formData: true
550 + hi: world
551 + multiple: 1,2
552 + content: [["hi","world"],["multiple","1"],["multiple","2"]]
553 + `);
554 + });
555
556 it('can transport cyclic objects', async () => {
557 function ComponentClient({prop}) {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+34 -40
@@ -15,11 +15,10 @@ global.ReadableStream =
15 require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16 global.TextEncoder = require('util').TextEncoder;
17 global.TextDecoder = require('util').TextDecoder;
18 -if (typeof Blob === 'undefined') {
19 - global.Blob = require('buffer').Blob;
20 -}
21 -if (typeof File === 'undefined') {
22 - global.File = require('buffer').File;
18 +global.Blob = require('buffer').Blob;
19 +if (typeof File === 'undefined' || typeof FormData === 'undefined') {
20 + global.File = require('buffer').File || require('undici').File;
21 + global.FormData = require('undici').FormData;
22 }
23 // Patch for Edge environments for global scope
24 global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage;
@@ -383,45 +382,40 @@ describe('ReactFlightDOMEdge', () => {
382 expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer());
383 });
384
386 - if (typeof FormData !== 'undefined' && typeof File !== 'undefined') {
387 - // @gate enableBinaryFlight
388 - it('can transport FormData (blobs)', async () => {
389 - const bytes = new Uint8Array([
390 - 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
391 - ]);
392 - const blob = new Blob([bytes, bytes], {
393 - type: 'application/x-test',
394 - });
385 + // @gate enableBinaryFlight
386 + it('can transport FormData (blobs)', async () => {
387 + const bytes = new Uint8Array([
388 + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
389 + ]);
390 + const blob = new Blob([bytes, bytes], {
391 + type: 'application/x-test',
392 + });
393
396 - const formData = new FormData();
397 - formData.append('hi', 'world');
398 - formData.append('file', blob, 'filename.test');
394 + const formData = new FormData();
395 + formData.append('hi', 'world');
396 + formData.append('file', blob, 'filename.test');
397
400 - expect(formData.get('file') instanceof File).toBe(true);
401 - expect(formData.get('file').name).toBe('filename.test');
398 + expect(formData.get('file') instanceof File).toBe(true);
399 + expect(formData.get('file').name).toBe('filename.test');
400
403 - const stream = passThrough(
404 - ReactServerDOMServer.renderToReadableStream(formData),
405 - );
406 - const result = await ReactServerDOMClient.createFromReadableStream(
407 - stream,
408 - {
409 - ssrManifest: {
410 - moduleMap: null,
411 - moduleLoading: null,
412 - },
413 - },
414 - );
415 -
416 - expect(result instanceof FormData).toBe(true);
417 - expect(result.get('hi')).toBe('world');
418 - const resultBlob = result.get('file');
419 - expect(resultBlob instanceof Blob).toBe(true);
420 - expect(resultBlob.name).toBe('blob'); // We should not pass through the file name for security.
421 - expect(resultBlob.size).toBe(bytes.length * 2);
422 - expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer());
401 + const stream = passThrough(
402 + ReactServerDOMServer.renderToReadableStream(formData),
403 + );
404 + const result = await ReactServerDOMClient.createFromReadableStream(stream, {
405 + ssrManifest: {
406 + moduleMap: null,
407 + moduleLoading: null,
408 + },
409 });
424 - }
410 +
411 + expect(result instanceof FormData).toBe(true);
412 + expect(result.get('hi')).toBe('world');
413 + const resultBlob = result.get('file');
414 + expect(resultBlob instanceof Blob).toBe(true);
415 + expect(resultBlob.name).toBe('blob'); // We should not pass through the file name for security.
416 + expect(resultBlob.size).toBe(bytes.length * 2);
417 + expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer());
418 + });
419
420 it('can pass an async import that resolves later to an outline object like a Map', async () => {
421 let resolve;
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js
+34 -42
@@ -16,11 +16,10 @@ global.ReadableStream =
16 global.TextEncoder = require('util').TextEncoder;
17 global.TextDecoder = require('util').TextDecoder;
18
19 -if (typeof Blob === 'undefined') {
20 - global.Blob = require('buffer').Blob;
21 -}
22 -if (typeof File === 'undefined') {
23 - global.File = require('buffer').File;
19 +global.Blob = require('buffer').Blob;
20 +if (typeof File === 'undefined' || typeof FormData === 'undefined') {
21 + global.File = require('buffer').File || require('undici').File;
22 + global.FormData = require('undici').FormData;
23 }
24
25 // let serverExports;
@@ -44,13 +43,6 @@ describe('ReactFlightDOMReplyEdge', () => {
43 ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
44 });
45
47 - if (typeof FormData === 'undefined') {
48 - // We can't test if we don't have a native FormData implementation because the JSDOM one
49 - // is missing the arrayBuffer() method.
50 - it('cannot test', () => {});
51 - return;
52 - }
53 -
46 it('can encode a reply', async () => {
47 const body = await ReactServerDOMClient.encodeReply({some: 'object'});
48 const decoded = await ReactServerDOMServer.decodeReply(
@@ -89,6 +81,8 @@ describe('ReactFlightDOMReplyEdge', () => {
81 );
82
83 expect(result).toEqual(buffers);
84 + // Array buffers can't use the toEqual helper.
85 + expect(new Uint8Array(result[0])).toEqual(new Uint8Array(buffers[0]));
86 });
87
88 // @gate enableBinaryFlight
@@ -109,35 +103,33 @@ describe('ReactFlightDOMReplyEdge', () => {
103 expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer());
104 });
105
112 - if (typeof FormData !== 'undefined' && typeof File !== 'undefined') {
113 - it('can transport FormData (blobs)', async () => {
114 - const bytes = new Uint8Array([
115 - 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
116 - ]);
117 - const blob = new Blob([bytes, bytes], {
118 - type: 'application/x-test',
119 - });
120 -
121 - const formData = new FormData();
122 - formData.append('hi', 'world');
123 - formData.append('file', blob, 'filename.test');
124 -
125 - expect(formData.get('file') instanceof File).toBe(true);
126 - expect(formData.get('file').name).toBe('filename.test');
127 -
128 - const body = await ReactServerDOMClient.encodeReply(formData);
129 - const result = await ReactServerDOMServer.decodeReply(
130 - body,
131 - webpackServerMap,
132 - );
133 -
134 - expect(result instanceof FormData).toBe(true);
135 - expect(result.get('hi')).toBe('world');
136 - const resultBlob = result.get('file');
137 - expect(resultBlob instanceof Blob).toBe(true);
138 - expect(resultBlob.name).toBe('filename.test'); // In this direction we allow file name to pass through but not other direction.
139 - expect(resultBlob.size).toBe(bytes.length * 2);
140 - expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer());
106 + it('can transport FormData (blobs)', async () => {
107 + const bytes = new Uint8Array([
108 + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
109 + ]);
110 + const blob = new Blob([bytes, bytes], {
111 + type: 'application/x-test',
112 });
142 - }
113 +
114 + const formData = new FormData();
115 + formData.append('hi', 'world');
116 + formData.append('file', blob, 'filename.test');
117 +
118 + expect(formData.get('file') instanceof File).toBe(true);
119 + expect(formData.get('file').name).toBe('filename.test');
120 +
121 + const body = await ReactServerDOMClient.encodeReply(formData);
122 + const result = await ReactServerDOMServer.decodeReply(
123 + body,
124 + webpackServerMap,
125 + );
126 +
127 + expect(result instanceof FormData).toBe(true);
128 + expect(result.get('hi')).toBe('world');
129 + const resultBlob = result.get('file');
130 + expect(resultBlob instanceof Blob).toBe(true);
131 + expect(resultBlob.name).toBe('filename.test'); // In this direction we allow file name to pass through but not other direction.
132 + expect(resultBlob.size).toBe(bytes.length * 2);
133 + expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer());
134 + });
135 });
yarn.lock
+12
@@ -2182,6 +2182,11 @@
2182 resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.0.0.tgz#1a9e4b4c96d8c7886e0110ed310a0135144a1691"
2183 integrity sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==
2184
2185 +"@fastify/busboy@^2.0.0":
2186 + version "2.1.1"
2187 + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d"
2188 + integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==
2189 +
2190 "@gitbeaker/core@^21.7.0":
2191 version "21.7.0"
2192 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:
15767 resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
15768 integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo=
15769
15770 +undici@^5.28.4:
15771 + version "5.28.4"
15772 + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068"
15773 + integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==
15774 + dependencies:
15775 + "@fastify/busboy" "^2.0.0"
15776 +
15777 unicode-canonical-property-names-ecmascript@^1.0.4:
15778 version "1.0.4"
15779 resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"