@samitouri / QOS-React-2 / commits / 6e169fc65d

[Flight] Allow String Chunks to Passthrough in Node streams and renderToMarkup (#30131)

It can be efficient to accept raw string chunks to pass through a stream instead of encoding them into a binary copy first. Previously our Flight parsers didn't accept receiving string chunks. That's partly because we sometimes need to encode binary chunks anyway so string only transport isn't enough but some chunks can be strings. This adds a partial ability for chunks to be received as strings. However, accepting strings comes with some downsides. E.g. if the strings are split up we need to buffer it which compromises the perf for the common case. If the chunk represents binary data, then we'd need to encode it back into a typed array which would require a TextEncoder dependency in the parser. If the string chunk represents a byte length encoded string we don't know how many unicode characters to read without measuring them in terms of binary - also requiring a TextEncoder. This PR is mainly intended for use for pass-through within the same memory. We can simplify the implementation by assuming that any string chunk is passed as the original chunk. This requires that the server stream config doesn't arbitrarily concatenate strings (e.g. large strings should not be concatenated which is probably a good heuristic anyway). It also means that this is not suitable to be used with for example receiving string chunks on the client by passing them through SSR hydration data - except if the encoding that way was only used with chunks that were already encoded as strings by Flight. Web streams mostly just work on binary data anyway so they can't use this. In Node.js streams we concatenate precomputed and small strings into larger buffers. It might make sense to do that using string ropes instead. However, in the meantime we can at least pass large strings that are outside our buffer view size as raw strings. There's no benefit to us eagerly encoding those. Also, let Node accept string chunks as long as they're following our expected constraints. This lets us test the mixed protocol using pass-throughs. This can also be useful when the RSC server is in the same environment as the SSR server as they don't have to go from strings to typed arrays back to strings. Now we can also use this in the pass-through used in renderToMarkup. This lets us avoid the dependency on TextDecoder/TextEncoder in that package.

Sebastian Markbåge committed Jul 3, 2024 at 13:25 UTC 6e169fc65da097629588132f8fec82d8a836afdd
7 files changed +186 -25
packages/react-client/src/ReactFlightClient.js
+156 -2
@@ -2121,7 +2121,7 @@ function resolveTypedArray(
2121 resolveBuffer(response, id, view);
2122 }
2123
2124 -function processFullRow(
2124 +function processFullBinaryRow(
2125 response: Response,
2126 id: number,
2127 tag: number,
@@ -2183,6 +2183,15 @@ function processFullRow(
2183 row += readPartialStringChunk(stringDecoder, buffer[i]);
2184 }
2185 row += readFinalStringChunk(stringDecoder, chunk);
2186 + processFullStringRow(response, id, tag, row);
2187 +}
2188 +
2189 +function processFullStringRow(
2190 + response: Response,
2191 + id: number,
2192 + tag: number,
2193 + row: string,
2194 +): void {
2195 switch (tag) {
2196 case 73 /* "I" */: {
2197 resolveModule(response, id, row);
@@ -2385,7 +2394,7 @@ export function processBinaryChunk(
2394 // We found the last chunk of the row
2395 const length = lastIdx - i;
2396 const lastChunk = new Uint8Array(chunk.buffer, offset, length);
2388 - processFullRow(response, rowID, rowTag, buffer, lastChunk);
2397 + processFullBinaryRow(response, rowID, rowTag, buffer, lastChunk);
2398 // Reset state machine for a new row
2399 i = lastIdx;
2400 if (rowState === ROW_CHUNK_BY_NEWLINE) {
@@ -2415,6 +2424,151 @@ export function processBinaryChunk(
2424 response._rowLength = rowLength;
2425 }
2426
2427 +export function processStringChunk(response: Response, chunk: string): void {
2428 + // This is a fork of processBinaryChunk that takes a string as input.
2429 + // This can't be just any binary chunk coverted to a string. It needs to be
2430 + // in the same offsets given from the Flight Server. E.g. if it's shifted by
2431 + // one byte then it won't line up to the UCS-2 encoding. It also needs to
2432 + // be valid Unicode. Also binary chunks cannot use this even if they're
2433 + // value Unicode. Large strings are encoded as binary and cannot be passed
2434 + // here. Basically, only if Flight Server gave you this string as a chunk,
2435 + // you can use it here.
2436 + let i = 0;
2437 + let rowState = response._rowState;
2438 + let rowID = response._rowID;
2439 + let rowTag = response._rowTag;
2440 + let rowLength = response._rowLength;
2441 + const buffer = response._buffer;
2442 + const chunkLength = chunk.length;
2443 + while (i < chunkLength) {
2444 + let lastIdx = -1;
2445 + switch (rowState) {
2446 + case ROW_ID: {
2447 + const byte = chunk.charCodeAt(i++);
2448 + if (byte === 58 /* ":" */) {
2449 + // Finished the rowID, next we'll parse the tag.
2450 + rowState = ROW_TAG;
2451 + } else {
2452 + rowID = (rowID << 4) | (byte > 96 ? byte - 87 : byte - 48);
2453 + }
2454 + continue;
2455 + }
2456 + case ROW_TAG: {
2457 + const resolvedRowTag = chunk.charCodeAt(i);
2458 + if (
2459 + resolvedRowTag === 84 /* "T" */ ||
2460 + (enableBinaryFlight &&
2461 + (resolvedRowTag === 65 /* "A" */ ||
2462 + resolvedRowTag === 79 /* "O" */ ||
2463 + resolvedRowTag === 111 /* "o" */ ||
2464 + resolvedRowTag === 85 /* "U" */ ||
2465 + resolvedRowTag === 83 /* "S" */ ||
2466 + resolvedRowTag === 115 /* "s" */ ||
2467 + resolvedRowTag === 76 /* "L" */ ||
2468 + resolvedRowTag === 108 /* "l" */ ||
2469 + resolvedRowTag === 71 /* "G" */ ||
2470 + resolvedRowTag === 103 /* "g" */ ||
2471 + resolvedRowTag === 77 /* "M" */ ||
2472 + resolvedRowTag === 109 /* "m" */ ||
2473 + resolvedRowTag === 86)) /* "V" */
2474 + ) {
2475 + rowTag = resolvedRowTag;
2476 + rowState = ROW_LENGTH;
2477 + i++;
2478 + } else if (
2479 + (resolvedRowTag > 64 && resolvedRowTag < 91) /* "A"-"Z" */ ||
2480 + resolvedRowTag === 114 /* "r" */ ||
2481 + resolvedRowTag === 120 /* "x" */
2482 + ) {
2483 + rowTag = resolvedRowTag;
2484 + rowState = ROW_CHUNK_BY_NEWLINE;
2485 + i++;
2486 + } else {
2487 + rowTag = 0;
2488 + rowState = ROW_CHUNK_BY_NEWLINE;
2489 + // This was an unknown tag so it was probably part of the data.
2490 + }
2491 + continue;
2492 + }
2493 + case ROW_LENGTH: {
2494 + const byte = chunk.charCodeAt(i++);
2495 + if (byte === 44 /* "," */) {
2496 + // Finished the rowLength, next we'll buffer up to that length.
2497 + rowState = ROW_CHUNK_BY_LENGTH;
2498 + } else {
2499 + rowLength = (rowLength << 4) | (byte > 96 ? byte - 87 : byte - 48);
2500 + }
2501 + continue;
2502 + }
2503 + case ROW_CHUNK_BY_NEWLINE: {
2504 + // We're looking for a newline
2505 + lastIdx = chunk.indexOf('\n', i);
2506 + break;
2507 + }
2508 + case ROW_CHUNK_BY_LENGTH: {
2509 + if (rowTag !== 84) {
2510 + throw new Error(
2511 + 'Binary RSC chunks cannot be encoded as strings. ' +
2512 + 'This is a bug in the wiring of the React streams.',
2513 + );
2514 + }
2515 + // For a large string by length, we don't know how many unicode characters
2516 + // we are looking for but we can assume that the raw string will be its own
2517 + // chunk. We add extra validation that the length is at least within the
2518 + // possible byte range it could possibly be to catch mistakes.
2519 + if (rowLength < chunk.length || chunk.length > rowLength * 3) {
2520 + throw new Error(
2521 + 'String chunks need to be passed in their original shape. ' +
2522 + 'Not split into smaller string chunks. ' +
2523 + 'This is a bug in the wiring of the React streams.',
2524 + );
2525 + }
2526 + lastIdx = chunk.length;
2527 + break;
2528 + }
2529 + }
2530 + if (lastIdx > -1) {
2531 + // We found the last chunk of the row
2532 + if (buffer.length > 0) {
2533 + // If we had a buffer already, it means that this chunk was split up into
2534 + // binary chunks preceeding it.
2535 + throw new Error(
2536 + 'String chunks need to be passed in their original shape. ' +
2537 + 'Not split into smaller string chunks. ' +
2538 + 'This is a bug in the wiring of the React streams.',
2539 + );
2540 + }
2541 + const lastChunk = chunk.slice(i, lastIdx);
2542 + processFullStringRow(response, rowID, rowTag, lastChunk);
2543 + // Reset state machine for a new row
2544 + i = lastIdx;
2545 + if (rowState === ROW_CHUNK_BY_NEWLINE) {
2546 + // If we're trailing by a newline we need to skip it.
2547 + i++;
2548 + }
2549 + rowState = ROW_ID;
2550 + rowTag = 0;
2551 + rowID = 0;
2552 + rowLength = 0;
2553 + buffer.length = 0;
2554 + } else if (chunk.length !== i) {
2555 + // The rest of this row is in a future chunk. We only support passing the
2556 + // string from chunks in their entirety. Not split up into smaller string chunks.
2557 + // We could support this by buffering them but we shouldn't need to for
2558 + // this use case.
2559 + throw new Error(
2560 + 'String chunks need to be passed in their original shape. ' +
2561 + 'Not split into smaller string chunks. ' +
2562 + 'This is a bug in the wiring of the React streams.',
2563 + );
2564 + }
2565 + }
2566 + response._rowState = rowState;
2567 + response._rowID = rowID;
2568 + response._rowTag = rowTag;
2569 + response._rowLength = rowLength;
2570 +}
2571 +
2572 function parseModel<T>(response: Response, json: UninitializedModel): T {
2573 return JSON.parse(json, response._fromJSON);
2574 }
packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js
+5 -9
@@ -7,26 +7,22 @@
7 * @flow
8 */
9
10 -// TODO: The legacy one should not use binary.
10 +export type StringDecoder = null;
11
12 -export type StringDecoder = TextDecoder;
13 -
14 -export function createStringDecoder(): StringDecoder {
15 - return new TextDecoder();
12 +export function createStringDecoder(): null {
13 + return null;
14 }
15
18 -const decoderOptions = {stream: true};
19 -
16 export function readPartialStringChunk(
17 decoder: StringDecoder,
18 buffer: Uint8Array,
19 ): string {
24 - return decoder.decode(buffer, decoderOptions);
20 + throw new Error('Not implemented.');
21 }
22
23 export function readFinalStringChunk(
24 decoder: StringDecoder,
25 buffer: Uint8Array,
26 ): string {
31 - return decoder.decode(buffer);
27 + throw new Error('Not implemented.');
28 }
packages/react-html/src/ReactHTMLServer.js
+2 -4
@@ -26,7 +26,7 @@ import {
26 import {
27 createResponse as createFlightResponse,
28 getRoot as getFlightRoot,
29 - processBinaryChunk as processFlightBinaryChunk,
29 + processStringChunk as processFlightStringChunk,
30 close as closeFlight,
31 } from 'react-client/src/ReactFlightClient';
32
@@ -80,12 +80,10 @@ export function renderToMarkup(
80 options?: MarkupOptions,
81 ): Promise<string> {
82 return new Promise((resolve, reject) => {
83 - const textEncoder = new TextEncoder();
83 const flightDestination = {
84 push(chunk: string | null): boolean {
85 if (chunk !== null) {
87 - // TODO: Legacy should not use binary streams.
88 - processFlightBinaryChunk(flightResponse, textEncoder.encode(chunk));
86 + processFlightStringChunk(flightResponse, chunk);
87 } else {
88 closeFlight(flightResponse);
89 }
packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js
+6 -1
@@ -30,6 +30,7 @@ import {
30 createResponse,
31 getRoot,
32 reportGlobalError,
33 + processStringChunk,
34 processBinaryChunk,
35 close,
36 } from 'react-client/src/ReactFlightClient';
@@ -79,7 +80,11 @@ function createFromNodeStream<T>(
80 : undefined,
81 );
82 stream.on('data', chunk => {
82 - processBinaryChunk(response, chunk);
83 + if (typeof chunk === 'string') {
84 + processStringChunk(response, chunk);
85 + } else {
86 + processBinaryChunk(response, chunk);
87 + }
88 });
89 stream.on('error', error => {
90 reportGlobalError(response, error);
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+12 -7
@@ -27,6 +27,11 @@ let use;
27 let ReactServerScheduler;
28 let reactServerAct;
29
30 +// We test pass-through without encoding strings but it should work without it too.
31 +const streamOptions = {
32 + objectMode: true,
33 +};
34 +
35 describe('ReactFlightDOMNode', () => {
36 beforeEach(() => {
37 jest.resetModules();
@@ -76,7 +81,7 @@ describe('ReactFlightDOMNode', () => {
81 function readResult(stream) {
82 return new Promise((resolve, reject) => {
83 let buffer = '';
79 - const writable = new Stream.PassThrough();
84 + const writable = new Stream.PassThrough(streamOptions);
85 writable.setEncoding('utf8');
86 writable.on('data', chunk => {
87 buffer += chunk;
@@ -128,7 +133,7 @@ describe('ReactFlightDOMNode', () => {
133 const stream = await serverAct(() =>
134 ReactServerDOMServer.renderToPipeableStream(<App />, webpackMap),
135 );
131 - const readable = new Stream.PassThrough();
136 + const readable = new Stream.PassThrough(streamOptions);
137 let response;
138
139 stream.pipe(readable);
@@ -160,7 +165,7 @@ describe('ReactFlightDOMNode', () => {
165 }),
166 );
167
163 - const readable = new Stream.PassThrough();
168 + const readable = new Stream.PassThrough(streamOptions);
169
170 const stringResult = readResult(readable);
171 const parsedResult = ReactServerDOMClient.createFromNodeStream(readable, {
@@ -206,7 +211,7 @@ describe('ReactFlightDOMNode', () => {
211 const stream = await serverAct(() =>
212 ReactServerDOMServer.renderToPipeableStream(buffers),
213 );
209 - const readable = new Stream.PassThrough();
214 + const readable = new Stream.PassThrough(streamOptions);
215 const promise = ReactServerDOMClient.createFromNodeStream(readable, {
216 moduleMap: {},
217 moduleLoading: webpackModuleLoading,
@@ -253,7 +258,7 @@ describe('ReactFlightDOMNode', () => {
258 const stream = await serverAct(() =>
259 ReactServerDOMServer.renderToPipeableStream(<App />, webpackMap),
260 );
256 - const readable = new Stream.PassThrough();
261 + const readable = new Stream.PassThrough(streamOptions);
262 let response;
263
264 stream.pipe(readable);
@@ -304,7 +309,7 @@ describe('ReactFlightDOMNode', () => {
309 ),
310 );
311
307 - const writable = new Stream.PassThrough();
312 + const writable = new Stream.PassThrough(streamOptions);
313 rscStream.pipe(writable);
314
315 controller.enqueue('hi');
@@ -349,7 +354,7 @@ describe('ReactFlightDOMNode', () => {
354 ),
355 );
356
352 - const readable = new Stream.PassThrough();
357 + const readable = new Stream.PassThrough(streamOptions);
358 rscStream.pipe(readable);
359
360 const result = await ReactServerDOMClient.createFromNodeStream(readable, {
packages/react-server/src/ReactServerStreamConfigNode.js
+2 -1
@@ -63,7 +63,8 @@ function writeStringChunk(destination: Destination, stringChunk: string) {
63 currentView = new Uint8Array(VIEW_SIZE);
64 writtenBytes = 0;
65 }
66 - writeToDestination(destination, textEncoder.encode(stringChunk));
66 + // Write the raw string chunk and let the consumer handle the encoding.
67 + writeToDestination(destination, stringChunk);
68 return;
69 }
70
scripts/error-codes/codes.json
+3 -1
@@ -523,5 +523,7 @@
523 "535": "renderToMarkup should not have emitted Server References. This is a bug in React.",
524 "536": "Cannot pass ref in renderToMarkup because they will never be hydrated.",
525 "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.",
526 - "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated."
526 + "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated.",
527 + "539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528 + "540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams."
529 }