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"