Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Flight Reply] Encode Typed Arrays and Blobs #28819

Merged
merged 2 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 10 additions & 10 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -1262,10 +1262,10 @@ function processFullRow(
// We must always clone to extract it into a separate buffer instead of just a view.
resolveBuffer(response, id, mergeBuffer(buffer, chunk).buffer);
return;
case 67 /* "C" */:
case 79 /* "O" */:
resolveTypedArray(response, id, buffer, chunk, Int8Array, 1);
return;
case 99 /* "c" */:
case 111 /* "o" */:
resolveBuffer(
response,
id,
Expand All @@ -1287,13 +1287,13 @@ function processFullRow(
case 108 /* "l" */:
resolveTypedArray(response, id, buffer, chunk, Uint32Array, 4);
return;
case 70 /* "F" */:
case 71 /* "G" */:
resolveTypedArray(response, id, buffer, chunk, Float32Array, 4);
return;
case 100 /* "d" */:
case 103 /* "g" */:
resolveTypedArray(response, id, buffer, chunk, Float64Array, 8);
return;
case 78 /* "N" */:
case 77 /* "M" */:
resolveTypedArray(response, id, buffer, chunk, BigInt64Array, 8);
return;
case 109 /* "m" */:
Expand Down Expand Up @@ -1417,16 +1417,16 @@ export function processBinaryChunk(
resolvedRowTag === 84 /* "T" */ ||
(enableBinaryFlight &&
(resolvedRowTag === 65 /* "A" */ ||
resolvedRowTag === 67 /* "C" */ ||
resolvedRowTag === 99 /* "c" */ ||
resolvedRowTag === 79 /* "O" */ ||
resolvedRowTag === 111 /* "o" */ ||
resolvedRowTag === 85 /* "U" */ ||
resolvedRowTag === 83 /* "S" */ ||
resolvedRowTag === 115 /* "s" */ ||
resolvedRowTag === 76 /* "L" */ ||
resolvedRowTag === 108 /* "l" */ ||
resolvedRowTag === 70 /* "F" */ ||
resolvedRowTag === 100 /* "d" */ ||
resolvedRowTag === 78 /* "N" */ ||
resolvedRowTag === 71 /* "G" */ ||
resolvedRowTag === 103 /* "g" */ ||
resolvedRowTag === 77 /* "M" */ ||
resolvedRowTag === 109 /* "m" */ ||
resolvedRowTag === 86)) /* "V" */
) {
Expand Down
86 changes: 85 additions & 1 deletion packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import type {
import type {LazyComponent} from 'react/src/ReactLazy';
import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences';

import {enableRenderableContext} from 'shared/ReactFeatureFlags';
import {
enableRenderableContext,
enableBinaryFlight,
} from 'shared/ReactFeatureFlags';

import {
REACT_ELEMENT_TYPE,
Expand Down Expand Up @@ -150,6 +153,10 @@ function serializeSetID(id: number): string {
return '$W' + id.toString(16);
}

function serializeBlobID(id: number): string {
return '$B' + id.toString(16);
}

function escapeStringValue(value: string): string {
if (value[0] === '$') {
// We need to escape $ prefixed strings since we use those to encode
Expand All @@ -171,6 +178,19 @@ export function processReply(
let pendingParts = 0;
let formData: null | FormData = null;

function serializeTypedArray(
tag: string,
typedArray: ArrayBuffer | $ArrayBufferView,
): string {
const blob = new Blob([typedArray]);
const blobId = nextPartId++;
if (formData === null) {
formData = new FormData();
}
formData.append(formFieldPrefix + blobId, blob);
return '$' + tag + blobId.toString(16);
}

function resolveToJSON(
this:
| {+[key: string | number]: ReactServerValue}
Expand Down Expand Up @@ -362,6 +382,70 @@ export function processReply(
formData.append(formFieldPrefix + setId, partJSON);
return serializeSetID(setId);
}

if (enableBinaryFlight) {
if (value instanceof ArrayBuffer) {
return serializeTypedArray('A', value);
}
if (value instanceof Int8Array) {
// char
return serializeTypedArray('O', value);
}
if (value instanceof Uint8Array) {
// unsigned char
return serializeTypedArray('o', value);
}
if (value instanceof Uint8ClampedArray) {
// unsigned clamped char
return serializeTypedArray('U', value);
}
if (value instanceof Int16Array) {
// sort
return serializeTypedArray('S', value);
}
if (value instanceof Uint16Array) {
// unsigned short
return serializeTypedArray('s', value);
}
if (value instanceof Int32Array) {
// long
return serializeTypedArray('L', value);
}
if (value instanceof Uint32Array) {
// unsigned long
return serializeTypedArray('l', value);
}
if (value instanceof Float32Array) {
// float
return serializeTypedArray('G', value);
}
if (value instanceof Float64Array) {
// double
return serializeTypedArray('g', value);
}
if (value instanceof BigInt64Array) {
// number
return serializeTypedArray('M', value);
}
if (value instanceof BigUint64Array) {
// unsigned number
// We use "m" instead of "n" since JSON can start with "null"
return serializeTypedArray('m', value);
}
if (value instanceof DataView) {
return serializeTypedArray('V', value);
}
// TODO: Blob is not available in old Node/browsers. Remove the typeof check later.
if (typeof Blob === 'function' && value instanceof Blob) {
if (formData === null) {
formData = new FormData();
}
const blobId = nextPartId++;
formData.append(formFieldPrefix + blobId, value);
return serializeBlobID(blobId);
}
}

const iteratorFn = getIteratorFn(value);
if (iteratorFn) {
return Array.from((value: any));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/

'use strict';
Expand All @@ -15,6 +16,13 @@ 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;
}

// let serverExports;
let webpackServerMap;
let ReactServerDOMServer;
Expand All @@ -36,6 +44,13 @@ 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(
Expand All @@ -45,4 +60,82 @@ describe('ReactFlightDOMReplyEdge', () => {

expect(decoded).toEqual({some: 'object'});
});

// @gate enableBinaryFlight
it('should be able to serialize any kind of typed array', async () => {
const buffer = new Uint8Array([
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
]).buffer;
const buffers = [
buffer,
new Int8Array(buffer, 1),
new Uint8Array(buffer, 2),
new Uint8ClampedArray(buffer, 2),
new Int16Array(buffer, 2),
new Uint16Array(buffer, 2),
new Int32Array(buffer, 4),
new Uint32Array(buffer, 4),
new Float32Array(buffer, 4),
new Float64Array(buffer, 0),
new BigInt64Array(buffer, 0),
new BigUint64Array(buffer, 0),
new DataView(buffer, 3),
];

const body = await ReactServerDOMClient.encodeReply(buffers);
const result = await ReactServerDOMServer.decodeReply(
body,
webpackServerMap,
);

expect(result).toEqual(buffers);
});

// @gate enableBinaryFlight
it('should be able to serialize a blob', 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 body = await ReactServerDOMClient.encodeReply(blob);
const result = await ReactServerDOMServer.decodeReply(
body,
webpackServerMap,
);
expect(result instanceof Blob).toBe(true);
expect(result.size).toBe(bytes.length * 2);
expect(await result.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());
});
});