Skip to content

Commit 6e169fc

Browse files
authored
[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.
1 parent 9c68069 commit 6e169fc

File tree

7 files changed

+186
-25
lines changed

7 files changed

+186
-25
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2121,7 +2121,7 @@ function resolveTypedArray(
21212121
resolveBuffer(response, id, view);
21222122
}
21232123

2124-
function processFullRow(
2124+
function processFullBinaryRow(
21252125
response: Response,
21262126
id: number,
21272127
tag: number,
@@ -2183,6 +2183,15 @@ function processFullRow(
21832183
row += readPartialStringChunk(stringDecoder, buffer[i]);
21842184
}
21852185
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 {
21862195
switch (tag) {
21872196
case 73 /* "I" */: {
21882197
resolveModule(response, id, row);
@@ -2385,7 +2394,7 @@ export function processBinaryChunk(
23852394
// We found the last chunk of the row
23862395
const length = lastIdx - i;
23872396
const lastChunk = new Uint8Array(chunk.buffer, offset, length);
2388-
processFullRow(response, rowID, rowTag, buffer, lastChunk);
2397+
processFullBinaryRow(response, rowID, rowTag, buffer, lastChunk);
23892398
// Reset state machine for a new row
23902399
i = lastIdx;
23912400
if (rowState === ROW_CHUNK_BY_NEWLINE) {
@@ -2415,6 +2424,151 @@ export function processBinaryChunk(
24152424
response._rowLength = rowLength;
24162425
}
24172426

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+
24182572
function parseModel<T>(response: Response, json: UninitializedModel): T {
24192573
return JSON.parse(json, response._fromJSON);
24202574
}

packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,22 @@
77
* @flow
88
*/
99

10-
// TODO: The legacy one should not use binary.
10+
export type StringDecoder = null;
1111

12-
export type StringDecoder = TextDecoder;
13-
14-
export function createStringDecoder(): StringDecoder {
15-
return new TextDecoder();
12+
export function createStringDecoder(): null {
13+
return null;
1614
}
1715

18-
const decoderOptions = {stream: true};
19-
2016
export function readPartialStringChunk(
2117
decoder: StringDecoder,
2218
buffer: Uint8Array,
2319
): string {
24-
return decoder.decode(buffer, decoderOptions);
20+
throw new Error('Not implemented.');
2521
}
2622

2723
export function readFinalStringChunk(
2824
decoder: StringDecoder,
2925
buffer: Uint8Array,
3026
): string {
31-
return decoder.decode(buffer);
27+
throw new Error('Not implemented.');
3228
}

packages/react-html/src/ReactHTMLServer.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
import {
2727
createResponse as createFlightResponse,
2828
getRoot as getFlightRoot,
29-
processBinaryChunk as processFlightBinaryChunk,
29+
processStringChunk as processFlightStringChunk,
3030
close as closeFlight,
3131
} from 'react-client/src/ReactFlightClient';
3232

@@ -80,12 +80,10 @@ export function renderToMarkup(
8080
options?: MarkupOptions,
8181
): Promise<string> {
8282
return new Promise((resolve, reject) => {
83-
const textEncoder = new TextEncoder();
8483
const flightDestination = {
8584
push(chunk: string | null): boolean {
8685
if (chunk !== null) {
87-
// TODO: Legacy should not use binary streams.
88-
processFlightBinaryChunk(flightResponse, textEncoder.encode(chunk));
86+
processFlightStringChunk(flightResponse, chunk);
8987
} else {
9088
closeFlight(flightResponse);
9189
}

packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
createResponse,
3131
getRoot,
3232
reportGlobalError,
33+
processStringChunk,
3334
processBinaryChunk,
3435
close,
3536
} from 'react-client/src/ReactFlightClient';
@@ -79,7 +80,11 @@ function createFromNodeStream<T>(
7980
: undefined,
8081
);
8182
stream.on('data', chunk => {
82-
processBinaryChunk(response, chunk);
83+
if (typeof chunk === 'string') {
84+
processStringChunk(response, chunk);
85+
} else {
86+
processBinaryChunk(response, chunk);
87+
}
8388
});
8489
stream.on('error', error => {
8590
reportGlobalError(response, error);

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ let use;
2727
let ReactServerScheduler;
2828
let reactServerAct;
2929

30+
// We test pass-through without encoding strings but it should work without it too.
31+
const streamOptions = {
32+
objectMode: true,
33+
};
34+
3035
describe('ReactFlightDOMNode', () => {
3136
beforeEach(() => {
3237
jest.resetModules();
@@ -76,7 +81,7 @@ describe('ReactFlightDOMNode', () => {
7681
function readResult(stream) {
7782
return new Promise((resolve, reject) => {
7883
let buffer = '';
79-
const writable = new Stream.PassThrough();
84+
const writable = new Stream.PassThrough(streamOptions);
8085
writable.setEncoding('utf8');
8186
writable.on('data', chunk => {
8287
buffer += chunk;
@@ -128,7 +133,7 @@ describe('ReactFlightDOMNode', () => {
128133
const stream = await serverAct(() =>
129134
ReactServerDOMServer.renderToPipeableStream(<App />, webpackMap),
130135
);
131-
const readable = new Stream.PassThrough();
136+
const readable = new Stream.PassThrough(streamOptions);
132137
let response;
133138

134139
stream.pipe(readable);
@@ -160,7 +165,7 @@ describe('ReactFlightDOMNode', () => {
160165
}),
161166
);
162167

163-
const readable = new Stream.PassThrough();
168+
const readable = new Stream.PassThrough(streamOptions);
164169

165170
const stringResult = readResult(readable);
166171
const parsedResult = ReactServerDOMClient.createFromNodeStream(readable, {
@@ -206,7 +211,7 @@ describe('ReactFlightDOMNode', () => {
206211
const stream = await serverAct(() =>
207212
ReactServerDOMServer.renderToPipeableStream(buffers),
208213
);
209-
const readable = new Stream.PassThrough();
214+
const readable = new Stream.PassThrough(streamOptions);
210215
const promise = ReactServerDOMClient.createFromNodeStream(readable, {
211216
moduleMap: {},
212217
moduleLoading: webpackModuleLoading,
@@ -253,7 +258,7 @@ describe('ReactFlightDOMNode', () => {
253258
const stream = await serverAct(() =>
254259
ReactServerDOMServer.renderToPipeableStream(<App />, webpackMap),
255260
);
256-
const readable = new Stream.PassThrough();
261+
const readable = new Stream.PassThrough(streamOptions);
257262
let response;
258263

259264
stream.pipe(readable);
@@ -304,7 +309,7 @@ describe('ReactFlightDOMNode', () => {
304309
),
305310
);
306311

307-
const writable = new Stream.PassThrough();
312+
const writable = new Stream.PassThrough(streamOptions);
308313
rscStream.pipe(writable);
309314

310315
controller.enqueue('hi');
@@ -349,7 +354,7 @@ describe('ReactFlightDOMNode', () => {
349354
),
350355
);
351356

352-
const readable = new Stream.PassThrough();
357+
const readable = new Stream.PassThrough(streamOptions);
353358
rscStream.pipe(readable);
354359

355360
const result = await ReactServerDOMClient.createFromNodeStream(readable, {

packages/react-server/src/ReactServerStreamConfigNode.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ function writeStringChunk(destination: Destination, stringChunk: string) {
6363
currentView = new Uint8Array(VIEW_SIZE);
6464
writtenBytes = 0;
6565
}
66-
writeToDestination(destination, textEncoder.encode(stringChunk));
66+
// Write the raw string chunk and let the consumer handle the encoding.
67+
writeToDestination(destination, stringChunk);
6768
return;
6869
}
6970

scripts/error-codes/codes.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,5 +523,7 @@
523523
"535": "renderToMarkup should not have emitted Server References. This is a bug in React.",
524524
"536": "Cannot pass ref in renderToMarkup because they will never be hydrated.",
525525
"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."
527529
}

0 commit comments

Comments
 (0)