From ffffcfe37a7f6bed668a1bd8fc23261ae86f8bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 29 Jun 2023 13:16:12 -0400 Subject: [PATCH] [Flight] Add Serialization of Typed Arrays / ArrayBuffer / DataView (#26954) This uses the same mechanism as [large strings](https://github.com/facebook/react/pull/26932) to encode chunks of length based binary data in the RSC payload behind a flag. I introduce a new BinaryChunk type that's specific to each stream and ways to convert into it. That's because we sometimes need all chunks to be Uint8Array for the output, even if the source is another array buffer view, and sometimes we need to clone it before transferring. Each type of typed array is its own row tag. This lets us ensure that the instance is directly in the right format in the cached entry instead of creating a wrapper at each reference. Ideally this is also how Map/Set should work but those are lazy which complicates that approach a bit. We assume both server and client use little-endian for now. If we want to support other modes, we'd convert it to/from little-endian so that the transfer protocol is always little-endian. That way the common clients can be the fastest possible. So far this only implements Server to Client. Still need to implement Client to Server for parity. NOTE: This is the first time we make RSC effectively a binary format. This is not compatible with existing SSR techniques which serialize the stream as unicode in the HTML. To be compatible, those implementations would have to use base64 or something like that. Which is what we'll do when we move this technique to be built-in to Fizz. --- .eslintrc.js | 3 + .../react-client/src/ReactFlightClient.js | 154 ++++++++++++++++-- .../ReactDOMLegacyServerStreamConfig.js | 15 +- .../src/ReactServerStreamConfigFB.js | 15 +- .../src/__tests__/ReactFlightDOMEdge-test.js | 27 +++ .../src/__tests__/ReactFlightDOMNode-test.js | 28 ++++ .../react-server/src/ReactFlightServer.js | 97 ++++++++++- .../src/ReactServerStreamConfigBrowser.js | 40 ++++- .../src/ReactServerStreamConfigBun.js | 18 +- .../src/ReactServerStreamConfigEdge.js | 40 ++++- .../src/ReactServerStreamConfigNode.js | 26 ++- .../forks/ReactServerStreamConfig.custom.js | 3 + packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 2 + scripts/rollup/validate/eslintrc.cjs.js | 18 +- scripts/rollup/validate/eslintrc.cjs2015.js | 18 +- scripts/rollup/validate/eslintrc.esm.js | 18 +- scripts/rollup/validate/eslintrc.fb.js | 18 +- scripts/rollup/validate/eslintrc.rn.js | 18 +- scripts/rollup/validate/eslintrc.umd.js | 18 +- 25 files changed, 517 insertions(+), 66 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 00e3094b06347..941c2e3b23ca8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -454,11 +454,14 @@ module.exports = { $PropertyType: 'readonly', $ReadOnly: 'readonly', $ReadOnlyArray: 'readonly', + $ArrayBufferView: 'readonly', $Shape: 'readonly', AnimationFrameID: 'readonly', // For Flow type annotation. Only `BigInt` is valid at runtime. bigint: 'readonly', BigInt: 'readonly', + BigInt64Array: 'readonly', + BigUint64Array: 'readonly', Class: 'readonly', ClientRect: 'readonly', CopyInspectedElementPath: 'readonly', diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 0143c0129ce11..522a11e6d9da8 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -21,6 +21,8 @@ import type {HintModel} from 'react-server/src/ReactFlightServerConfig'; import type {CallServerCallback} from './ReactFlightReplyClient'; +import {enableBinaryFlight} from 'shared/ReactFeatureFlags'; + import { resolveClientReference, preloadModule, @@ -297,6 +299,14 @@ function createInitializedTextChunk( return new Chunk(INITIALIZED, value, null, response); } +function createInitializedBufferChunk( + response: Response, + value: $ArrayBufferView | ArrayBuffer, +): InitializedChunk { + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors + return new Chunk(INITIALIZED, value, null, response); +} + function resolveModelChunk( chunk: SomeChunk, value: UninitializedModel, @@ -738,6 +748,16 @@ function resolveText(response: Response, id: number, text: string): void { chunks.set(id, createInitializedTextChunk(response, text)); } +function resolveBuffer( + response: Response, + id: number, + buffer: $ArrayBufferView | ArrayBuffer, +): void { + const chunks = response._chunks; + // We assume that we always reference buffers after they've been emitted. + chunks.set(id, createInitializedBufferChunk(response, buffer)); +} + function resolveModule( response: Response, id: number, @@ -856,24 +876,120 @@ function resolveHint( dispatchHint(code, hintModel); } +function mergeBuffer( + buffer: Array, + lastChunk: Uint8Array, +): Uint8Array { + const l = buffer.length; + // Count the bytes we'll need + let byteLength = lastChunk.length; + for (let i = 0; i < l; i++) { + byteLength += buffer[i].byteLength; + } + // Allocate enough contiguous space + const result = new Uint8Array(byteLength); + let offset = 0; + // Copy all the buffers into it. + for (let i = 0; i < l; i++) { + const chunk = buffer[i]; + result.set(chunk, offset); + offset += chunk.byteLength; + } + result.set(lastChunk, offset); + return result; +} + +function resolveTypedArray( + response: Response, + id: number, + buffer: Array, + lastChunk: Uint8Array, + constructor: any, + bytesPerElement: number, +): void { + // If the view fits into one original buffer, we just reuse that buffer instead of + // copying it out to a separate copy. This means that it's not always possible to + // transfer these values to other threads without copying first since they may + // share array buffer. For this to work, it must also have bytes aligned to a + // multiple of a size of the type. + const chunk = + buffer.length === 0 && lastChunk.byteOffset % bytesPerElement === 0 + ? lastChunk + : mergeBuffer(buffer, lastChunk); + // TODO: The transfer protocol of RSC is little-endian. If the client isn't little-endian + // we should convert it instead. In practice big endian isn't really Web compatible so it's + // somewhat safe to assume that browsers aren't going to run it, but maybe there's some SSR + // server that's affected. + const view: $ArrayBufferView = new constructor( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength / bytesPerElement, + ); + resolveBuffer(response, id, view); +} + function processFullRow( response: Response, id: number, tag: number, buffer: Array, - lastChunk: string | Uint8Array, + chunk: Uint8Array, ): void { - let row = ''; + if (enableBinaryFlight) { + switch (tag) { + case 65 /* "A" */: + // 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" */: + resolveTypedArray(response, id, buffer, chunk, Int8Array, 1); + return; + case 99 /* "c" */: + resolveBuffer( + response, + id, + buffer.length === 0 ? chunk : mergeBuffer(buffer, chunk), + ); + return; + case 85 /* "U" */: + resolveTypedArray(response, id, buffer, chunk, Uint8ClampedArray, 1); + return; + case 83 /* "S" */: + resolveTypedArray(response, id, buffer, chunk, Int16Array, 2); + return; + case 115 /* "s" */: + resolveTypedArray(response, id, buffer, chunk, Uint16Array, 2); + return; + case 76 /* "L" */: + resolveTypedArray(response, id, buffer, chunk, Int32Array, 4); + return; + case 108 /* "l" */: + resolveTypedArray(response, id, buffer, chunk, Uint32Array, 4); + return; + case 70 /* "F" */: + resolveTypedArray(response, id, buffer, chunk, Float32Array, 4); + return; + case 68 /* "D" */: + resolveTypedArray(response, id, buffer, chunk, Float64Array, 8); + return; + case 78 /* "N" */: + resolveTypedArray(response, id, buffer, chunk, BigInt64Array, 8); + return; + case 109 /* "m" */: + resolveTypedArray(response, id, buffer, chunk, BigUint64Array, 8); + return; + case 86 /* "V" */: + resolveTypedArray(response, id, buffer, chunk, DataView, 1); + return; + } + } + const stringDecoder = response._stringDecoder; + let row = ''; for (let i = 0; i < buffer.length; i++) { - const chunk = buffer[i]; - row += readPartialStringChunk(stringDecoder, chunk); - } - if (typeof lastChunk === 'string') { - row += lastChunk; - } else { - row += readFinalStringChunk(stringDecoder, lastChunk); + row += readPartialStringChunk(stringDecoder, buffer[i]); } + row += readFinalStringChunk(stringDecoder, chunk); switch (tag) { case 73 /* "I" */: { resolveModule(response, id, row); @@ -903,7 +1019,7 @@ function processFullRow( resolveText(response, id, row); return; } - default: { + default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ { // We assume anything else is JSON. resolveModel(response, id, row); return; @@ -937,7 +1053,23 @@ export function processBinaryChunk( } case ROW_TAG: { const resolvedRowTag = chunk[i]; - if (resolvedRowTag === 84 /* "T" */) { + if ( + resolvedRowTag === 84 /* "T" */ || + (enableBinaryFlight && + (resolvedRowTag === 65 /* "A" */ || + resolvedRowTag === 67 /* "C" */ || + resolvedRowTag === 99 /* "c" */ || + resolvedRowTag === 85 /* "U" */ || + resolvedRowTag === 83 /* "S" */ || + resolvedRowTag === 115 /* "s" */ || + resolvedRowTag === 76 /* "L" */ || + resolvedRowTag === 108 /* "l" */ || + resolvedRowTag === 70 /* "F" */ || + resolvedRowTag === 68 /* "D" */ || + resolvedRowTag === 78 /* "N" */ || + resolvedRowTag === 109 /* "m" */ || + resolvedRowTag === 86)) /* "V" */ + ) { rowTag = resolvedRowTag; rowState = ROW_LENGTH; i++; diff --git a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js index 5d055026492f3..c682865cabafd 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -14,6 +14,7 @@ export interface Destination { export opaque type PrecomputedChunk = string; export opaque type Chunk = string; +export opaque type BinaryChunk = string; export function scheduleWork(callback: () => void) { callback(); @@ -25,14 +26,14 @@ export function beginWriting(destination: Destination) {} export function writeChunk( destination: Destination, - chunk: Chunk | PrecomputedChunk, + chunk: Chunk | PrecomputedChunk | BinaryChunk, ): void { writeChunkAndReturn(destination, chunk); } export function writeChunkAndReturn( destination: Destination, - chunk: Chunk | PrecomputedChunk, + chunk: Chunk | PrecomputedChunk | BinaryChunk, ): boolean { return destination.push(chunk); } @@ -51,6 +52,12 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk { return content; } +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + throw new Error('Not implemented.'); +} + export function clonePrecomputedChunk( chunk: PrecomputedChunk, ): PrecomputedChunk { @@ -61,6 +68,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { throw new Error('Not implemented.'); } +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + throw new Error('Not implemented.'); +} + export function closeWithError(destination: Destination, error: mixed): void { // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. destination.destroy(error); diff --git a/packages/react-server-dom-fb/src/ReactServerStreamConfigFB.js b/packages/react-server-dom-fb/src/ReactServerStreamConfigFB.js index 71f1949d2a17b..8321bdc62e551 100644 --- a/packages/react-server-dom-fb/src/ReactServerStreamConfigFB.js +++ b/packages/react-server-dom-fb/src/ReactServerStreamConfigFB.js @@ -16,6 +16,7 @@ export type Destination = { export opaque type PrecomputedChunk = string; export opaque type Chunk = string; +export opaque type BinaryChunk = string; export function scheduleWork(callback: () => void) { // We don't schedule work in this model, and instead expect performWork to always be called repeatedly. @@ -30,14 +31,14 @@ export function beginWriting(destination: Destination) {} export function writeChunk( destination: Destination, - chunk: Chunk | PrecomputedChunk, + chunk: Chunk | PrecomputedChunk | BinaryChunk, ): void { destination.buffer += chunk; } export function writeChunkAndReturn( destination: Destination, - chunk: Chunk | PrecomputedChunk, + chunk: Chunk | PrecomputedChunk | BinaryChunk, ): boolean { destination.buffer += chunk; return true; @@ -57,6 +58,12 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk { return content; } +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + throw new Error('Not implemented.'); +} + export function clonePrecomputedChunk( chunk: PrecomputedChunk, ): PrecomputedChunk { @@ -67,6 +74,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { throw new Error('Not implemented.'); } +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + throw new Error('Not implemented.'); +} + export function closeWithError(destination: Destination, error: mixed): void { destination.done = true; destination.fatal = true; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index a08925af7bf9f..d728623671422 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -153,4 +153,31 @@ describe('ReactFlightDOMEdge', () => { expect(result.text).toBe(testString); expect(result.text2).toBe(testString2); }); + + // @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 stream = passThrough( + ReactServerDOMServer.renderToReadableStream(buffers), + ); + const result = await ReactServerDOMClient.createFromReadableStream(stream); + expect(result).toEqual(buffers); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 4eed4c562152c..cb7f7e84e5ff3 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -131,4 +131,32 @@ describe('ReactFlightDOMNode', () => { // Should still match the result when parsed expect(result.text).toBe(testString); }); + + // @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 stream = ReactServerDOMServer.renderToPipeableStream(buffers); + const readable = new Stream.PassThrough(); + const promise = ReactServerDOMClient.createFromNodeStream(readable); + stream.pipe(readable); + const result = await promise; + expect(result).toEqual(buffers); + }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index fa7ef0e12f57e..e8e18d758dd3d 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -7,7 +7,9 @@ * @flow */ -import type {Chunk, Destination} from './ReactServerStreamConfig'; +import type {Chunk, BinaryChunk, Destination} from './ReactServerStreamConfig'; + +import {enableBinaryFlight} from 'shared/ReactFeatureFlags'; import { scheduleWork, @@ -15,7 +17,9 @@ import { beginWriting, writeChunkAndReturn, stringToChunk, + typedArrayToBinaryChunk, byteLengthOfChunk, + byteLengthOfBinaryChunk, completeWriting, close, closeWithError, @@ -176,7 +180,7 @@ export type Request = { pingedTasks: Array, completedImportChunks: Array, completedHintChunks: Array, - completedRegularChunks: Array, + completedRegularChunks: Array, completedErrorChunks: Array, writtenSymbols: Map, writtenClientReferences: Map, @@ -235,7 +239,7 @@ export function createRequest( pingedTasks: pingedTasks, completedImportChunks: ([]: Array), completedHintChunks: ([]: Array), - completedRegularChunks: ([]: Array), + completedRegularChunks: ([]: Array), completedErrorChunks: ([]: Array), writtenSymbols: new Map(), writtenClientReferences: new Map(), @@ -733,7 +737,6 @@ function serializeLargeTextString(request: Request, text: string): string { const headerChunk = processTextHeader( request, textId, - text, byteLengthOfChunk(textChunk), ); request.completedRegularChunks.push(headerChunk, textChunk); @@ -753,6 +756,25 @@ function serializeSet(request: Request, set: Set): string { return '$W' + id.toString(16); } +function serializeTypedArray( + request: Request, + tag: string, + typedArray: $ArrayBufferView, +): string { + request.pendingChunks += 2; + const bufferId = request.nextChunkId++; + // TODO: Convert to little endian if that's not the server default. + const binaryChunk = typedArrayToBinaryChunk(typedArray); + const headerChunk = processBufferHeader( + request, + tag, + bufferId, + byteLengthOfBinaryChunk(binaryChunk), + ); + request.completedRegularChunks.push(headerChunk, binaryChunk); + return serializeByValueID(bufferId); +} + function escapeStringValue(value: string): string { if (value[0] === '$') { // We need to escape $ prefixed strings since we use those to encode @@ -942,12 +964,68 @@ function resolveModelToJSON( } return (undefined: any); } + if (value instanceof Map) { return serializeMap(request, value); } if (value instanceof Set) { return serializeSet(request, value); } + + if (enableBinaryFlight) { + if (value instanceof ArrayBuffer) { + return serializeTypedArray(request, 'A', new Uint8Array(value)); + } + if (value instanceof Int8Array) { + // char + return serializeTypedArray(request, 'C', value); + } + if (value instanceof Uint8Array) { + // unsigned char + return serializeTypedArray(request, 'c', value); + } + if (value instanceof Uint8ClampedArray) { + // unsigned clamped char + return serializeTypedArray(request, 'U', value); + } + if (value instanceof Int16Array) { + // sort + return serializeTypedArray(request, 'S', value); + } + if (value instanceof Uint16Array) { + // unsigned short + return serializeTypedArray(request, 's', value); + } + if (value instanceof Int32Array) { + // long + return serializeTypedArray(request, 'L', value); + } + if (value instanceof Uint32Array) { + // unsigned long + return serializeTypedArray(request, 'l', value); + } + if (value instanceof Float32Array) { + // float + return serializeTypedArray(request, 'F', value); + } + if (value instanceof Float64Array) { + // double + return serializeTypedArray(request, 'D', value); + } + if (value instanceof BigInt64Array) { + // number + return serializeTypedArray(request, 'N', value); + } + if (value instanceof BigUint64Array) { + // unsigned number + // We use "m" instead of "n" since JSON can start with "null" + return serializeTypedArray(request, 'm', value); + } + if (value instanceof DataView) { + return serializeTypedArray(request, 'V', value); + } + } + if (!isArray(value)) { const iteratorFn = getIteratorFn(value); if (iteratorFn) { @@ -1593,9 +1671,18 @@ function processHintChunk( function processTextHeader( request: Request, id: number, - text: string, binaryLength: number, ): Chunk { const row = id.toString(16) + ':T' + binaryLength.toString(16) + ','; return stringToChunk(row); } + +function processBufferHeader( + request: Request, + tag: string, + id: number, + binaryLength: number, +): Chunk { + const row = id.toString(16) + ':' + tag + binaryLength.toString(16) + ','; + return stringToChunk(row); +} diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index 44b5e5af839b8..c6f2ba8d7cf0a 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -11,6 +11,7 @@ export type Destination = ReadableStreamController; export type PrecomputedChunk = Uint8Array; export opaque type Chunk = Uint8Array; +export type BinaryChunk = Uint8Array; export function scheduleWork(callback: () => void) { callback(); @@ -32,13 +33,13 @@ export function beginWriting(destination: Destination) { export function writeChunk( destination: Destination, - chunk: PrecomputedChunk | Chunk, + chunk: PrecomputedChunk | Chunk | BinaryChunk, ): void { - if (chunk.length === 0) { + if (chunk.byteLength === 0) { return; } - if (chunk.length > VIEW_SIZE) { + if (chunk.byteLength > VIEW_SIZE) { if (__DEV__) { if (precomputedChunkSet.has(chunk)) { console.error( @@ -68,7 +69,7 @@ export function writeChunk( let bytesToWrite = chunk; const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes; - if (allowableBytes < bytesToWrite.length) { + if (allowableBytes < bytesToWrite.byteLength) { // this chunk would overflow the current view. We enqueue a full view // and start a new view with the remaining chunk if (allowableBytes === 0) { @@ -89,12 +90,12 @@ export function writeChunk( writtenBytes = 0; } ((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes); - writtenBytes += bytesToWrite.length; + writtenBytes += bytesToWrite.byteLength; } export function writeChunkAndReturn( destination: Destination, - chunk: PrecomputedChunk | Chunk, + chunk: PrecomputedChunk | Chunk | BinaryChunk, ): boolean { writeChunk(destination, chunk); // in web streams there is no backpressure so we can alwas write more @@ -119,7 +120,9 @@ export function stringToChunk(content: string): Chunk { return textEncoder.encode(content); } -const precomputedChunkSet: Set = __DEV__ ? new Set() : (null: any); +const precomputedChunkSet: Set = __DEV__ + ? new Set() + : (null: any); export function stringToPrecomputedChunk(content: string): PrecomputedChunk { const precomputedChunk = textEncoder.encode(content); @@ -131,10 +134,27 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk { return precomputedChunk; } +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + // Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays. + // If we passed through this straight to enqueue we wouldn't have to convert it but since + // we need to copy the buffer in that case, we need to convert it to copy it. + // When we copy it into another array using set() it needs to be a Uint8Array. + const buffer = new Uint8Array( + content.buffer, + content.byteOffset, + content.byteLength, + ); + // We clone large chunks so that we can transfer them when we write them. + // Others get copied into the target buffer. + return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer; +} + export function clonePrecomputedChunk( precomputedChunk: PrecomputedChunk, ): PrecomputedChunk { - return precomputedChunk.length > VIEW_SIZE + return precomputedChunk.byteLength > VIEW_SIZE ? precomputedChunk.slice() : precomputedChunk; } @@ -143,6 +163,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { return chunk.byteLength; } +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + return chunk.byteLength; +} + export function closeWithError(destination: Destination, error: mixed): void { // $FlowFixMe[method-unbinding] if (typeof destination.error === 'function') { diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index ac245209d53d0..27317f0925cd4 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -9,13 +9,14 @@ type BunReadableStreamController = ReadableStreamController & { end(): mixed, - write(data: Chunk): void, + write(data: Chunk | BinaryChunk): void, error(error: Error): void, }; export type Destination = BunReadableStreamController; export type PrecomputedChunk = string; export opaque type Chunk = string; +export type BinaryChunk = $ArrayBufferView; export function scheduleWork(callback: () => void) { callback(); @@ -30,7 +31,7 @@ export function beginWriting(destination: Destination) {} export function writeChunk( destination: Destination, - chunk: PrecomputedChunk | Chunk, + chunk: PrecomputedChunk | Chunk | BinaryChunk, ): void { if (chunk.length === 0) { return; @@ -41,7 +42,7 @@ export function writeChunk( export function writeChunkAndReturn( destination: Destination, - chunk: PrecomputedChunk | Chunk, + chunk: PrecomputedChunk | Chunk | BinaryChunk, ): boolean { return !!destination.write(chunk); } @@ -60,6 +61,13 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk { return content; } +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + // TODO: Does this needs to be cloned if it's transferred in enqueue()? + return content; +} + export function clonePrecomputedChunk( chunk: PrecomputedChunk, ): PrecomputedChunk { @@ -70,6 +78,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { return Buffer.byteLength(chunk, 'utf8'); } +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + return chunk.byteLength; +} + export function closeWithError(destination: Destination, error: mixed): void { if (typeof destination.error === 'function') { // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. diff --git a/packages/react-server/src/ReactServerStreamConfigEdge.js b/packages/react-server/src/ReactServerStreamConfigEdge.js index 00b0f4077d5b9..b665a13706edc 100644 --- a/packages/react-server/src/ReactServerStreamConfigEdge.js +++ b/packages/react-server/src/ReactServerStreamConfigEdge.js @@ -11,6 +11,7 @@ export type Destination = ReadableStreamController; export type PrecomputedChunk = Uint8Array; export opaque type Chunk = Uint8Array; +export type BinaryChunk = Uint8Array; export function scheduleWork(callback: () => void) { setTimeout(callback, 0); @@ -32,13 +33,13 @@ export function beginWriting(destination: Destination) { export function writeChunk( destination: Destination, - chunk: PrecomputedChunk | Chunk, + chunk: PrecomputedChunk | Chunk | BinaryChunk, ): void { - if (chunk.length === 0) { + if (chunk.byteLength === 0) { return; } - if (chunk.length > VIEW_SIZE) { + if (chunk.byteLength > VIEW_SIZE) { if (__DEV__) { if (precomputedChunkSet.has(chunk)) { console.error( @@ -68,7 +69,7 @@ export function writeChunk( let bytesToWrite = chunk; const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes; - if (allowableBytes < bytesToWrite.length) { + if (allowableBytes < bytesToWrite.byteLength) { // this chunk would overflow the current view. We enqueue a full view // and start a new view with the remaining chunk if (allowableBytes === 0) { @@ -89,12 +90,12 @@ export function writeChunk( writtenBytes = 0; } ((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes); - writtenBytes += bytesToWrite.length; + writtenBytes += bytesToWrite.byteLength; } export function writeChunkAndReturn( destination: Destination, - chunk: PrecomputedChunk | Chunk, + chunk: PrecomputedChunk | Chunk | BinaryChunk, ): boolean { writeChunk(destination, chunk); // in web streams there is no backpressure so we can alwas write more @@ -119,7 +120,9 @@ export function stringToChunk(content: string): Chunk { return textEncoder.encode(content); } -const precomputedChunkSet: Set = __DEV__ ? new Set() : (null: any); +const precomputedChunkSet: Set = __DEV__ + ? new Set() + : (null: any); export function stringToPrecomputedChunk(content: string): PrecomputedChunk { const precomputedChunk = textEncoder.encode(content); @@ -131,10 +134,27 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk { return precomputedChunk; } +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + // Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays. + // If we passed through this straight to enqueue we wouldn't have to convert it but since + // we need to copy the buffer in that case, we need to convert it to copy it. + // When we copy it into another array using set() it needs to be a Uint8Array. + const buffer = new Uint8Array( + content.buffer, + content.byteOffset, + content.byteLength, + ); + // We clone large chunks so that we can transfer them when we write them. + // Others get copied into the target buffer. + return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer; +} + export function clonePrecomputedChunk( precomputedChunk: PrecomputedChunk, ): PrecomputedChunk { - return precomputedChunk.length > VIEW_SIZE + return precomputedChunk.byteLength > VIEW_SIZE ? precomputedChunk.slice() : precomputedChunk; } @@ -143,6 +163,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { return chunk.byteLength; } +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + return chunk.byteLength; +} + export function closeWithError(destination: Destination, error: mixed): void { // $FlowFixMe[method-unbinding] if (typeof destination.error === 'function') { diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index 12814e36e0b47..d6784e3c77b3a 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -19,6 +19,7 @@ export type Destination = Writable & MightBeFlushable; export type PrecomputedChunk = Uint8Array; export opaque type Chunk = string; +export type BinaryChunk = Uint8Array; export function scheduleWork(callback: () => void) { setImmediate(callback); @@ -89,7 +90,10 @@ function writeStringChunk(destination: Destination, stringChunk: string) { } } -function writeViewChunk(destination: Destination, chunk: PrecomputedChunk) { +function writeViewChunk( + destination: Destination, + chunk: PrecomputedChunk | BinaryChunk, +) { if (chunk.byteLength === 0) { return; } @@ -152,16 +156,19 @@ function writeViewChunk(destination: Destination, chunk: PrecomputedChunk) { export function writeChunk( destination: Destination, - chunk: PrecomputedChunk | Chunk, + chunk: PrecomputedChunk | Chunk | BinaryChunk, ): void { if (typeof chunk === 'string') { writeStringChunk(destination, chunk); } else { - writeViewChunk(destination, ((chunk: any): PrecomputedChunk)); + writeViewChunk(destination, ((chunk: any): PrecomputedChunk | BinaryChunk)); } } -function writeToDestination(destination: Destination, view: Uint8Array) { +function writeToDestination( + destination: Destination, + view: string | Uint8Array, +) { const currentHasCapacity = destination.write(view); destinationHasCapacity = destinationHasCapacity && currentHasCapacity; } @@ -207,6 +214,13 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk { return precomputedChunk; } +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + // Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays. + return new Uint8Array(content.buffer, content.byteOffset, content.byteLength); +} + export function clonePrecomputedChunk( precomputedChunk: PrecomputedChunk, ): PrecomputedChunk { @@ -221,6 +235,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { : chunk.byteLength; } +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + return chunk.byteLength; +} + export function closeWithError(destination: Destination, error: mixed): void { // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. destination.destroy(error); diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js index 913bb56d67e64..23bd4c35ddfa5 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js @@ -28,6 +28,7 @@ export opaque type Destination = mixed; // eslint-disable-line no-undef export opaque type PrecomputedChunk = mixed; // eslint-disable-line no-undef export opaque type Chunk = mixed; // eslint-disable-line no-undef +export opaque type BinaryChunk = mixed; // eslint-disable-line no-undef export const scheduleWork = $$$config.scheduleWork; export const beginWriting = $$$config.beginWriting; @@ -39,5 +40,7 @@ export const close = $$$config.close; export const closeWithError = $$$config.closeWithError; export const stringToChunk = $$$config.stringToChunk; export const stringToPrecomputedChunk = $$$config.stringToPrecomputedChunk; +export const typedArrayToBinaryChunk = $$$config.typedArrayToBinaryChunk; export const clonePrecomputedChunk = $$$config.clonePrecomputedChunk; export const byteLengthOfChunk = $$$config.byteLengthOfChunk; +export const byteLengthOfBinaryChunk = $$$config.byteLengthOfBinaryChunk; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 86aaaa9403268..3813798d09b3e 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -84,6 +84,8 @@ export const enableFetchInstrumentation = true; export const enableFormActions = __EXPERIMENTAL__; +export const enableBinaryFlight = __EXPERIMENTAL__; + export const enableTransitionTracing = false; // No known bugs, but needs performance testing diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 85f9ef17c329d..70909c55a0309 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -36,6 +36,7 @@ export const enableLegacyCache = false; export const enableCacheElement = true; export const enableFetchInstrumentation = false; export const enableFormActions = true; // Doesn't affect Native +export const enableBinaryFlight = true; export const enableSchedulerDebugging = false; export const debugRenderPhaseSideEffectsForStrictMode = true; export const disableJavaScriptURLs = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 1ec92ff86e116..675f937d58d49 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -24,6 +24,7 @@ export const enableLegacyCache = false; export const enableCacheElement = false; export const enableFetchInstrumentation = false; export const enableFormActions = true; // Doesn't affect Native +export const enableBinaryFlight = true; export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index b1e9fc806358a..e83c4dab44b9d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -24,6 +24,7 @@ export const enableLegacyCache = __EXPERIMENTAL__; export const enableCacheElement = __EXPERIMENTAL__; export const enableFetchInstrumentation = true; export const enableFormActions = true; // Doesn't affect Test Renderer +export const enableBinaryFlight = true; export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 049854054cfeb..9ddc9004125f5 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -24,6 +24,7 @@ export const enableLegacyCache = false; export const enableCacheElement = true; export const enableFetchInstrumentation = false; export const enableFormActions = true; // Doesn't affect Test Renderer +export const enableBinaryFlight = true; export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index a702ec223d234..bdfc1acd77eed 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -24,6 +24,7 @@ export const enableLegacyCache = true; export const enableCacheElement = true; export const enableFetchInstrumentation = false; export const enableFormActions = true; // Doesn't affect Test Renderer +export const enableBinaryFlight = true; export const enableSchedulerDebugging = false; export const disableJavaScriptURLs = false; export const disableCommentsAsDOMContainers = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 48ca992b9b302..6321770d7946a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -75,6 +75,8 @@ export const enableFetchInstrumentation = false; export const enableFormActions = false; +export const enableBinaryFlight = true; + export const disableJavaScriptURLs = true; // TODO: www currently relies on this feature. It's disabled in open source. diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index e8c2943b93349..313f9639fb984 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -14,7 +14,21 @@ module.exports = { Symbol: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + + Int8Array: 'readonly', + Uint8Array: 'readonly', + Uint8ClampedArray: 'readonly', + Int16Array: 'readonly', Uint16Array: 'readonly', + Int32Array: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Float64Array: 'readonly', + BigInt64Array: 'readonly', + BigUint64Array: 'readonly', + DataView: 'readonly', + ArrayBuffer: 'readonly', + Reflect: 'readonly', globalThis: 'readonly', // Vendor specific @@ -28,15 +42,11 @@ module.exports = { trustedTypes: 'readonly', // Scheduler profiling - Int32Array: 'readonly', - ArrayBuffer: 'readonly', - TaskController: 'readonly', reportError: 'readonly', AggregateError: 'readonly', // Flight - Uint8Array: 'readonly', Promise: 'readonly', // Temp diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js index e5b79f84c3e09..dc35c98311863 100644 --- a/scripts/rollup/validate/eslintrc.cjs2015.js +++ b/scripts/rollup/validate/eslintrc.cjs2015.js @@ -14,7 +14,21 @@ module.exports = { Symbol: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + + Int8Array: 'readonly', + Uint8Array: 'readonly', + Uint8ClampedArray: 'readonly', + Int16Array: 'readonly', Uint16Array: 'readonly', + Int32Array: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Float64Array: 'readonly', + BigInt64Array: 'readonly', + BigUint64Array: 'readonly', + DataView: 'readonly', + ArrayBuffer: 'readonly', + Reflect: 'readonly', globalThis: 'readonly', // Vendor specific @@ -28,15 +42,11 @@ module.exports = { trustedTypes: 'readonly', // Scheduler profiling - Int32Array: 'readonly', - ArrayBuffer: 'readonly', - TaskController: 'readonly', reportError: 'readonly', AggregateError: 'readonly', // Flight - Uint8Array: 'readonly', Promise: 'readonly', // Temp diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js index a9bf9ab5a0bcc..ae1846c08d7ba 100644 --- a/scripts/rollup/validate/eslintrc.esm.js +++ b/scripts/rollup/validate/eslintrc.esm.js @@ -14,7 +14,21 @@ module.exports = { Symbol: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + + Int8Array: 'readonly', + Uint8Array: 'readonly', + Uint8ClampedArray: 'readonly', + Int16Array: 'readonly', Uint16Array: 'readonly', + Int32Array: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Float64Array: 'readonly', + BigInt64Array: 'readonly', + BigUint64Array: 'readonly', + DataView: 'readonly', + ArrayBuffer: 'readonly', + Reflect: 'readonly', globalThis: 'readonly', // Vendor specific @@ -28,15 +42,11 @@ module.exports = { trustedTypes: 'readonly', // Scheduler profiling - Int32Array: 'readonly', - ArrayBuffer: 'readonly', - TaskController: 'readonly', reportError: 'readonly', AggregateError: 'readonly', // Flight - Uint8Array: 'readonly', Promise: 'readonly', // Temp diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index 122e7e3fd4c4d..94483e5fe075a 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -14,7 +14,21 @@ module.exports = { Proxy: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + + Int8Array: 'readonly', + Uint8Array: 'readonly', + Uint8ClampedArray: 'readonly', + Int16Array: 'readonly', Uint16Array: 'readonly', + Int32Array: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Float64Array: 'readonly', + BigInt64Array: 'readonly', + BigUint64Array: 'readonly', + DataView: 'readonly', + ArrayBuffer: 'readonly', + Reflect: 'readonly', globalThis: 'readonly', // Vendor specific @@ -29,15 +43,11 @@ module.exports = { trustedTypes: 'readonly', // Scheduler profiling - Int32Array: 'readonly', - ArrayBuffer: 'readonly', - TaskController: 'readonly', reportError: 'readonly', AggregateError: 'readonly', // Flight - Uint8Array: 'readonly', Promise: 'readonly', // Temp diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js index 000c1ae92a7a3..9038701285521 100644 --- a/scripts/rollup/validate/eslintrc.rn.js +++ b/scripts/rollup/validate/eslintrc.rn.js @@ -14,6 +14,21 @@ module.exports = { Proxy: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + + Int8Array: 'readonly', + Uint8Array: 'readonly', + Uint8ClampedArray: 'readonly', + Int16Array: 'readonly', + Uint16Array: 'readonly', + Int32Array: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Float64Array: 'readonly', + BigInt64Array: 'readonly', + BigUint64Array: 'readonly', + DataView: 'readonly', + ArrayBuffer: 'readonly', + Reflect: 'readonly', globalThis: 'readonly', // Vendor specific @@ -29,9 +44,6 @@ module.exports = { // RN supports this setImmediate: 'readonly', // Scheduler profiling - Int32Array: 'readonly', - ArrayBuffer: 'readonly', - TaskController: 'readonly', reportError: 'readonly', AggregateError: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.umd.js b/scripts/rollup/validate/eslintrc.umd.js index 17eae00040551..020bfe0bb6b0c 100644 --- a/scripts/rollup/validate/eslintrc.umd.js +++ b/scripts/rollup/validate/eslintrc.umd.js @@ -13,7 +13,21 @@ module.exports = { Proxy: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + + Int8Array: 'readonly', + Uint8Array: 'readonly', + Uint8ClampedArray: 'readonly', + Int16Array: 'readonly', Uint16Array: 'readonly', + Int32Array: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Float64Array: 'readonly', + BigInt64Array: 'readonly', + BigUint64Array: 'readonly', + DataView: 'readonly', + ArrayBuffer: 'readonly', + Reflect: 'readonly', globalThis: 'readonly', // Vendor specific @@ -33,15 +47,11 @@ module.exports = { trustedTypes: 'readonly', // Scheduler profiling - Int32Array: 'readonly', - ArrayBuffer: 'readonly', - TaskController: 'readonly', reportError: 'readonly', AggregateError: 'readonly', // Flight - Uint8Array: 'readonly', Promise: 'readonly', // Temp