diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 840b49fceae16..b4739f4d720e3 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -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, @@ -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" */: @@ -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" */ ) { diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 21fd5e565230b..d90a3a509b0b2 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -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, @@ -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 @@ -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} @@ -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)); 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 8e45472956294..d7000de7f3526 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment */ 'use strict'; @@ -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; @@ -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( @@ -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()); + }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 98d9c0fe046e6..e1933b17db4c3 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -25,6 +25,7 @@ import { } from 'react-client/src/ReactFlightClientConfig'; import {createTemporaryReference} from './ReactFlightServerTemporaryReferences'; +import {enableBinaryFlight} from 'shared/ReactFeatureFlags'; export type JSONValue = | number @@ -378,9 +379,41 @@ function getOutlinedModel(response: Response, id: number): any { return chunk.value; } -function parseModelString( +function parseTypedArray( response: Response, + reference: string, + constructor: any, + bytesPerElement: number, parentObject: Object, + parentKey: string, +): null { + const id = parseInt(reference.slice(2), 16); + const prefix = response._prefix; + const key = prefix + id; + // We should have this backingEntry in the store already because we emitted + // it before referencing it. It should be a Blob. + const backingEntry: Blob = (response._formData.get(key): any); + + const promise = + constructor === ArrayBuffer + ? backingEntry.arrayBuffer() + : backingEntry.arrayBuffer().then(function (buffer) { + return new constructor(buffer); + }); + + // Since loading the buffer is an async operation we'll be blocking the parent + // chunk. TODO: This is not safe if the parent chunk needs a mapper like Map. + const parentChunk = initializingChunk; + promise.then( + createModelResolver(parentChunk, parentObject, parentKey), + createModelReject(parentChunk), + ); + return null; +} + +function parseModelString( + response: Response, + obj: Object, key: string, value: string, ): any { @@ -407,7 +440,7 @@ function parseModelString( metaData.id, metaData.bound, initializingChunk, - parentObject, + obj, key, ); } @@ -473,32 +506,78 @@ function parseModelString( // BigInt return BigInt(value.slice(2)); } - default: { - // We assume that anything else is a reference ID. - const id = parseInt(value.slice(1), 16); - const chunk = getChunk(response, id); - switch (chunk.status) { - case RESOLVED_MODEL: - initializeModelChunk(chunk); - break; - } - // The status might have changed after initialization. - switch (chunk.status) { - case INITIALIZED: - return chunk.value; - case PENDING: - case BLOCKED: - const parentChunk = initializingChunk; - chunk.then( - createModelResolver(parentChunk, parentObject, key), - createModelReject(parentChunk), - ); - return null; - default: - throw chunk.reason; + } + if (enableBinaryFlight) { + switch (value[1]) { + case 'A': + return parseTypedArray(response, value, ArrayBuffer, 1, obj, key); + case 'O': + return parseTypedArray(response, value, Int8Array, 1, obj, key); + case 'o': + return parseTypedArray(response, value, Uint8Array, 1, obj, key); + case 'U': + return parseTypedArray( + response, + value, + Uint8ClampedArray, + 1, + obj, + key, + ); + case 'S': + return parseTypedArray(response, value, Int16Array, 2, obj, key); + case 's': + return parseTypedArray(response, value, Uint16Array, 2, obj, key); + case 'L': + return parseTypedArray(response, value, Int32Array, 4, obj, key); + case 'l': + return parseTypedArray(response, value, Uint32Array, 4, obj, key); + case 'G': + return parseTypedArray(response, value, Float32Array, 4, obj, key); + case 'g': + return parseTypedArray(response, value, Float64Array, 8, obj, key); + case 'M': + return parseTypedArray(response, value, BigInt64Array, 8, obj, key); + case 'm': + return parseTypedArray(response, value, BigUint64Array, 8, obj, key); + case 'V': + return parseTypedArray(response, value, DataView, 1, obj, key); + case 'B': { + // Blob + const id = parseInt(value.slice(2), 16); + const prefix = response._prefix; + const blobKey = prefix + id; + // We should have this backingEntry in the store already because we emitted + // it before referencing it. It should be a Blob. + const backingEntry: Blob = (response._formData.get(blobKey): any); + return backingEntry; } } } + + // We assume that anything else is a reference ID. + const id = parseInt(value.slice(1), 16); + const chunk = getChunk(response, id); + switch (chunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(chunk); + break; + } + // The status might have changed after initialization. + switch (chunk.status) { + case INITIALIZED: + return chunk.value; + case PENDING: + case BLOCKED: + const parentChunk = initializingChunk; + chunk.then( + createModelResolver(parentChunk, obj, key), + createModelReject(parentChunk), + ); + return null; + default: + throw chunk.reason; + } } return value; } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 900793d24c0b5..3c8ac8c1469a8 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1610,11 +1610,11 @@ function renderModelDestructive( } if (value instanceof Int8Array) { // char - return serializeTypedArray(request, 'C', value); + return serializeTypedArray(request, 'O', value); } if (value instanceof Uint8Array) { // unsigned char - return serializeTypedArray(request, 'c', value); + return serializeTypedArray(request, 'o', value); } if (value instanceof Uint8ClampedArray) { // unsigned clamped char @@ -1638,15 +1638,15 @@ function renderModelDestructive( } if (value instanceof Float32Array) { // float - return serializeTypedArray(request, 'F', value); + return serializeTypedArray(request, 'G', value); } if (value instanceof Float64Array) { // double - return serializeTypedArray(request, 'd', value); + return serializeTypedArray(request, 'g', value); } if (value instanceof BigInt64Array) { // number - return serializeTypedArray(request, 'N', value); + return serializeTypedArray(request, 'M', value); } if (value instanceof BigUint64Array) { // unsigned number @@ -2158,11 +2158,11 @@ function renderConsoleValue( } if (value instanceof Int8Array) { // char - return serializeTypedArray(request, 'C', value); + return serializeTypedArray(request, 'O', value); } if (value instanceof Uint8Array) { // unsigned char - return serializeTypedArray(request, 'c', value); + return serializeTypedArray(request, 'o', value); } if (value instanceof Uint8ClampedArray) { // unsigned clamped char @@ -2186,15 +2186,15 @@ function renderConsoleValue( } if (value instanceof Float32Array) { // float - return serializeTypedArray(request, 'F', value); + return serializeTypedArray(request, 'G', value); } if (value instanceof Float64Array) { // double - return serializeTypedArray(request, 'd', value); + return serializeTypedArray(request, 'g', value); } if (value instanceof BigInt64Array) { // number - return serializeTypedArray(request, 'N', value); + return serializeTypedArray(request, 'M', value); } if (value instanceof BigUint64Array) { // unsigned number