From 17e920c00d314bfc6833e99ff4cb5c91fb3da254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 15 Apr 2024 22:32:08 -0400 Subject: [PATCH] [Flight Reply] Encode Typed Arrays and Blobs (#28819) With the enableBinaryFlight flag on we should encode typed arrays and blobs in the Reply direction too for parity. It's already possible to pass Blobs inside FormData but you should be able to pass them inside objects too. We encode typed arrays as blobs and then unwrap them automatically to the right typed array type. Unlike the other protocol, I encode the type as a reference tag instead of row tag. Therefore I need to rename the tags to avoid conflicts with other tags in references. We are running out of characters though. --- .../react-client/src/ReactFlightClient.js | 20 +-- .../src/ReactFlightReplyClient.js | 86 +++++++++++- .../__tests__/ReactFlightDOMReplyEdge-test.js | 93 +++++++++++++ .../src/ReactFlightReplyServer.js | 129 ++++++++++++++---- .../react-server/src/ReactFlightServer.js | 20 +-- 5 files changed, 302 insertions(+), 46 deletions(-) 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