Skip to content

Commit 5fcfd71

Browse files
authored
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.
1 parent d5c3034 commit 5fcfd71

File tree

6 files changed

+132
-113
lines changed

6 files changed

+132
-113
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"through2": "^3.0.1",
9898
"tmp": "^0.1.0",
9999
"typescript": "^3.7.5",
100+
"undici": "^5.28.4",
100101
"web-streams-polyfill": "^3.1.1",
101102
"yargs": "^15.3.1"
102103
},

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,17 @@ export function processReply(
187187

188188
function serializeTypedArray(
189189
tag: string,
190-
typedArray: ArrayBuffer | $ArrayBufferView,
190+
typedArray: $ArrayBufferView,
191191
): 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+
]);
193201
const blobId = nextPartId++;
194202
if (formData === null) {
195203
formData = new FormData();
@@ -392,7 +400,13 @@ export function processReply(
392400

393401
if (enableBinaryFlight) {
394402
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);
396410
}
397411
if (value instanceof Int8Array) {
398412
// char

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010

1111
'use strict';
1212

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+
1321
function normalizeCodeLocInfo(str) {
1422
return (
1523
str &&
@@ -513,39 +521,37 @@ describe('ReactFlight', () => {
513521
`);
514522
});
515523

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);
532534

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);
534539

535-
const transport = ReactNoopFlightServer.render(model);
540+
const model = <Component prop={formData} />;
536541

537-
await act(async () => {
538-
ReactNoop.render(await ReactNoopFlightClient.read(transport));
539-
});
542+
const transport = ReactNoopFlightServer.render(model);
540543

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));
547546
});
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+
});
549555

550556
it('can transport cyclic objects', async () => {
551557
function ComponentClient({prop}) {

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js

Lines changed: 34 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@ global.ReadableStream =
1515
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
1616
global.TextEncoder = require('util').TextEncoder;
1717
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;
2322
}
2423
// Patch for Edge environments for global scope
2524
global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage;
@@ -383,45 +382,40 @@ describe('ReactFlightDOMEdge', () => {
383382
expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer());
384383
});
385384

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+
});
395393

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');
399397

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');
402400

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+
},
423409
});
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+
});
425419

426420
it('can pass an async import that resolves later to an outline object like a Map', async () => {
427421
let resolve;

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js

Lines changed: 34 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ global.ReadableStream =
1616
global.TextEncoder = require('util').TextEncoder;
1717
global.TextDecoder = require('util').TextDecoder;
1818

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;
2423
}
2524

2625
// let serverExports;
@@ -44,13 +43,6 @@ describe('ReactFlightDOMReplyEdge', () => {
4443
ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
4544
});
4645

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-
5446
it('can encode a reply', async () => {
5547
const body = await ReactServerDOMClient.encodeReply({some: 'object'});
5648
const decoded = await ReactServerDOMServer.decodeReply(
@@ -89,6 +81,8 @@ describe('ReactFlightDOMReplyEdge', () => {
8981
);
9082

9183
expect(result).toEqual(buffers);
84+
// Array buffers can't use the toEqual helper.
85+
expect(new Uint8Array(result[0])).toEqual(new Uint8Array(buffers[0]));
9286
});
9387

9488
// @gate enableBinaryFlight
@@ -109,35 +103,33 @@ describe('ReactFlightDOMReplyEdge', () => {
109103
expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer());
110104
});
111105

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',
141112
});
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+
});
143135
});

yarn.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,6 +2182,11 @@
21822182
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.0.0.tgz#1a9e4b4c96d8c7886e0110ed310a0135144a1691"
21832183
integrity sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==
21842184

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+
21852190
"@gitbeaker/core@^21.7.0":
21862191
version "21.7.0"
21872192
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:
1576215767
resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
1576315768
integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo=
1576415769

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+
1576515777
unicode-canonical-property-names-ecmascript@^1.0.4:
1576615778
version "1.0.4"
1576715779
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"

0 commit comments

Comments
 (0)