Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[Flight] Fix broken byte stream parsing caused by buffer detachment (… · react/react@93fc574 · GitHub
Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix broken byte stream parsing caused by buffer detachment (… · react/react@93fc574 · GitHub
Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix broken byte stream parsing caused by buffer detachment (… · react/react@93fc574 · GitHub
Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [Flight] Fix broken byte stream parsing caused by buffer detachment (… · react/react@93fc574 · GitHub
Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix broken byte stream parsing caused by buffer detachment (… · react/react@93fc574 · GitHub
Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix broken byte stream parsing caused by buffer detachment (… · react/react@93fc574 · GitHub
Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [Flight] Fix broken byte stream parsing caused by buffer detachment (… · react/react@93fc574 · GitHub
Skip to content

Commit 93fc574

Browse files
authored
[Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})` instances passed through React Server Components (RSC) would stall after reading only the first chunk or the first few chunks in the client. This issue was masked by using `web-streams-polyfill` in tests, but manifests with native Web Streams implementations. The root cause is that when a chunk is enqueued to a `ReadableByteStreamController`, the spec requires the underlying ArrayBuffer to be synchronously transferred/detached. In the React Flight Client's chunk parsing, embedded byte stream chunks are created as views into the incoming RSC stream chunk buffer using `new Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream chunks are enqueued, they can detach the shared buffer, leaving the RSC stream parsing in a broken state. The fix is to copy embedded byte stream chunks before enqueueing them, preventing buffer detachment from affecting subsequent parsing. To not affect performance too much, we use a zero-copy optimization: when a chunk ends exactly at the end of the RSC stream chunk, or when the row spans into the next RSC chunk, no further parsing will access that buffer, so we can safely enqueue the view directly without copying. We now also enqueue embedded byte stream chunks immediately as they are parsed, without waiting for the full row to complete. To simplify the logic in the client, we introduce a new `'b'` protocol tag specifically for byte stream chunks. The server now emits `'b'` instead of `'o'` for `Uint8Array` chunks from byte streams (detected via `supportsBYOB`). This allows the client to recognize byte stream chunks without needing to track stream IDs. Tests now use the proper Jest environment with native Web Streams instead of polyfills, exposing and validating the fix for this issue.
1 parent 093b324 commit 93fc574

6 files changed

Lines changed: 202 additions & 96 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,7 @@ export function processBinaryChunk(
48574857
resolvedRowTag===65/* "A" */||
48584858
resolvedRowTag===79/* "O" */||
48594859
resolvedRowTag===111/* "o" */||
4860+
resolvedRowTag===98/* "b" */||
48604861
resolvedRowTag===85/* "U" */||
48614862
resolvedRowTag===83/* "S" */||
48624863
resolvedRowTag===115/* "s" */||
@@ -4916,14 +4917,31 @@ export function processBinaryChunk(
49164917
// We found the last chunk of the row
49174918
constlength=lastIdx-i;
49184919
constlastChunk=newUint8Array(chunk.buffer,offset,length);
4919-
processFullBinaryRow(
4920-
response,
4921-
streamState,
4922-
rowID,
4923-
rowTag,
4924-
buffer,
4925-
lastChunk,
4926-
);
4920+
4921+
// Check if this is a Uint8Array for a byte stream. We enqueue it
4922+
// immediately but need to determine if we can use zero-copy or must copy.
4923+
if(rowTag===98/* "b" */){
4924+
resolveBuffer(
4925+
response,
4926+
rowID,
4927+
// If we're at the end of the RSC chunk, no more parsing will access
4928+
// this buffer and we don't need to copy the chunk to allow detaching
4929+
// the buffer, otherwise we need to copy.
4930+
lastIdx===chunkLength ? lastChunk : lastChunk.slice(),
4931+
streamState,
4932+
);
4933+
}else{
4934+
// Process all other row types.
4935+
processFullBinaryRow(
4936+
response,
4937+
streamState,
4938+
rowID,
4939+
rowTag,
4940+
buffer,
4941+
lastChunk,
4942+
);
4943+
}
4944+
49274945
// Reset state machine for a new row
49284946
i=lastIdx;
49294947
if(rowState===ROW_CHUNK_BY_NEWLINE){
@@ -4936,14 +4954,27 @@ export function processBinaryChunk(
49364954
rowLength=0;
49374955
buffer.length=0;
49384956
} else {
4939-
// The rest of this row is in a future chunk. We stash the rest of the
4940-
// current chunk until we can process the full row.
4957+
// The rest of this row is in a future chunk.
49414958
constlength=chunk.byteLength-i;
49424959
constremainingSlice=newUint8Array(chunk.buffer,offset,length);
4943-
buffer.push(remainingSlice);
4944-
// Update how many bytes we're still waiting for. If we're looking for
4945-
// a newline, this doesn't hurt since we'll just ignore it.
4946-
rowLength-=remainingSlice.byteLength;
4960+
4961+
// For byte streams, we can enqueue the partial row immediately without
4962+
// copying since we're at the end of the RSC chunk and no more parsing
4963+
// will access this buffer.
4964+
if(rowTag===98/* "b" */){
4965+
// Update how many bytes we're still waiting for. We need to do this
4966+
// before enqueueing, as enqueue will detach the buffer and byteLength
4967+
// will become 0.
4968+
rowLength-=remainingSlice.byteLength;
4969+
resolveBuffer(response,rowID,remainingSlice,streamState);
4970+
} else {
4971+
// For other row types, stash the rest of the current chunk until we can
4972+
// process the full row.
4973+
buffer.push(remainingSlice);
4974+
// Update how many bytes we're still waiting for. If we're looking for
4975+
// a newline, this doesn't hurt since we'll just ignore it.
4976+
rowLength-=remainingSlice.byteLength;
4977+
}
49474978
break;
49484979
}
49494980
}

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.WritableStream=
16-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
17-
global.TextEncoder=require('util').TextEncoder;
18-
global.TextDecoder=require('util').TextDecoder;
19-
2013
letclientExports;
2114
letturbopackMap;
2215
letturbopackModules;

‎packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReplyEdge-test.js‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
89
*/
910

1011
'use strict';
1112

12-
// Polyfills for test environment
13-
global.ReadableStream=
14-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15-
global.TextEncoder=require('util').TextEncoder;
16-
global.TextDecoder=require('util').TextDecoder;
17-
18-
// let serverExports;
1913
letturbopackServerMap;
2014
letReactServerDOMServer;
2115
letReactServerDOMClient;
@@ -29,7 +23,6 @@ describe('ReactFlightDOMTurbopackReply', () => {
2923
require('react-server-dom-turbopack/server.edge'),
3024
);
3125
constTurbopackMock=require('./utils/TurbopackMock');
32-
// serverExports = TurbopackMock.serverExports;
3326
turbopackServerMap=TurbopackMock.turbopackServerMap;
3427
ReactServerDOMServer=require('react-server-dom-turbopack/server.edge');
3528
jest.resetModules();

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.WritableStream=
17-
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18-
global.TextEncoder=require('util').TextEncoder;
19-
global.TextDecoder=require('util').TextDecoder;
20-
global.Blob=require('buffer').Blob;
21-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
22-
global.File=require('buffer').File||require('undici').File;
23-
global.FormData=require('undici').FormData;
24-
}
2513
// Patch for Edge environments for global scope
2614
global.AsyncLocalStorage=require('async_hooks').AsyncLocalStorage;
2715

@@ -127,8 +115,16 @@ describe('ReactFlightDOMEdge', () => {
127115
chunk.set(prevChunk,0);
128116
chunk.set(value,prevChunk.length);
129117
if(chunk.length>50){
118+
// Copy the part we're keeping (prevChunk) to avoid buffer
119+
// transfer. When we enqueue the partial chunk below, downstream
120+
// consumers (like byte streams in the Flight Client) may detach
121+
// the underlying buffer. Since prevChunk would share the same
122+
// buffer, we copy it first so it has its own independent buffer.
123+
// TODO: Should we just use {type: 'bytes'} for this stream to
124+
// always transfer ownership, and not only "accidentally" when we
125+
// enqueue in the Flight Client?
126+
prevChunk=chunk.slice(chunk.length-50);
130127
controller.enqueue(chunk.subarray(0,chunk.length-50));
131-
prevChunk=chunk.subarray(chunk.length-50);
132128
}else{
133129
// Wait to see if we get some more bytes to join in.
134130
prevChunk=chunk;
@@ -1118,25 +1114,121 @@ describe('ReactFlightDOMEdge', () => {
11181114
expect(streamedBuffers).toEqual(buffers);
11191115
});
11201116

1117+
it('should support binary ReadableStreams',async()=>{
1118+
constencoder=newTextEncoder();
1119+
constwords=['Hello','streaming','world'];
1120+
1121+
conststream=newReadableStream({
1122+
type: 'bytes',
1123+
asyncstart(controller){
1124+
for(leti=0;i<words.length;i++){
1125+
constchunk=encoder.encode(words[i]+' ');
1126+
controller.enqueue(chunk);
1127+
}
1128+
controller.close();
1129+
},
1130+
});
1131+
1132+
constrscStream=awaitserverAct(()=>
1133+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1134+
);
1135+
1136+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1137+
rscStream,
1138+
{
1139+
serverConsumerManifest: {
1140+
moduleMap: null,
1141+
moduleLoading: null,
1142+
},
1143+
},
1144+
);
1145+
1146+
constreader=result.getReader();
1147+
constdecoder=newTextDecoder();
1148+
1149+
lettext='';
1150+
letentry;
1151+
while(!(entry=awaitreader.read()).done){
1152+
text+=decoder.decode(entry.value);
1153+
}
1154+
1155+
expect(text).toBe('Hello streaming world ');
1156+
});
1157+
1158+
it('should support large binary ReadableStreams',async()=>{
1159+
constchunkCount=100;
1160+
constchunkSize=1024;
1161+
constexpectedBytes=[];
1162+
1163+
conststream=newReadableStream({
1164+
type: 'bytes',
1165+
start(controller){
1166+
for(leti=0;i<chunkCount;i++){
1167+
constchunk=newUint8Array(chunkSize);
1168+
for(letj=0;j<chunkSize;j++){
1169+
chunk[j]=(i+j)%256;
1170+
}
1171+
expectedBytes.push(...Array.from(chunk));
1172+
controller.enqueue(chunk);
1173+
}
1174+
controller.close();
1175+
},
1176+
});
1177+
1178+
constrscStream=awaitserverAct(()=>
1179+
ReactServerDOMServer.renderToReadableStream(stream,{}),
1180+
);
1181+
1182+
constresult=awaitReactServerDOMClient.createFromReadableStream(
1183+
// Use passThrough to split and rejoin chunks at arbitrary boundaries.
1184+
passThrough(rscStream),
1185+
{
1186+
serverConsumerManifest: {
1187+
moduleMap: null,
1188+
moduleLoading: null,
1189+
},
1190+
},
1191+
);
1192+
1193+
constreader=result.getReader();
1194+
constreceivedBytes=[];
1195+
letentry;
1196+
while(!(entry=awaitreader.read()).done){
1197+
expect(entry.valueinstanceofUint8Array).toBe(true);
1198+
receivedBytes.push(...Array.from(entry.value));
1199+
}
1200+
1201+
expect(receivedBytes).toEqual(expectedBytes);
1202+
});
1203+
11211204
it('should support BYOB binary ReadableStreams',async()=>{
1122-
constbuffer=newUint8Array([
1205+
constsourceBytes=[
11231206
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
1124-
]).buffer;
1207+
];
1208+
1209+
// Create separate buffers for each typed array to avoid ArrayBuffer
1210+
// transfer issues. Each view needs its own buffer because enqueue()
1211+
// transfers ownership.
11251212
constbuffers=[
1126-
newInt8Array(buffer,1),
1127-
newUint8Array(buffer,2),
1128-
newUint8ClampedArray(buffer,2),
1129-
newInt16Array(buffer,2),
1130-
newUint16Array(buffer,2),
1131-
newInt32Array(buffer,4),
1132-
newUint32Array(buffer,4),
1133-
newFloat32Array(buffer,4),
1134-
newFloat64Array(buffer,0),
1135-
newBigInt64Array(buffer,0),
1136-
newBigUint64Array(buffer,0),
1137-
newDataView(buffer,3),
1213+
newInt8Array(sourceBytes.slice(1)),
1214+
newUint8Array(sourceBytes.slice(2)),
1215+
newUint8ClampedArray(sourceBytes.slice(2)),
1216+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1217+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
1218+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1219+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1220+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
1221+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1222+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1223+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
1224+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
11381225
];
11391226

1227+
// Save expected bytes before enqueueing (which will detach the buffers).
1228+
constexpectedBytes=buffers.flatMap(c=>
1229+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1230+
);
1231+
11401232
// This a binary stream where each chunk ends up as Uint8Array.
11411233
consts=newReadableStream({
11421234
type: 'bytes',
@@ -1176,11 +1268,7 @@ describe('ReactFlightDOMEdge', () => {
11761268

11771269
// The streamed buffers might be in different chunks and in Uint8Array form but
11781270
// the concatenated bytes should be the same.
1179-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
1180-
buffers.flatMap(c=>
1181-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
1182-
),
1183-
);
1271+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
11841272
});
11851273

11861274
// @gate !__DEV__ || enableComponentPerformanceTrack

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,6 @@
1010

1111
'use strict';
1212

13-
// Polyfills for test environment
14-
global.ReadableStream=
15-
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
16-
global.TextEncoder=require('util').TextEncoder;
17-
global.TextDecoder=require('util').TextDecoder;
18-
19-
global.Blob=require('buffer').Blob;
20-
if(typeofFile==='undefined'||typeofFormData==='undefined'){
21-
global.File=require('buffer').File||require('undici').File;
22-
global.FormData=require('undici').FormData;
23-
}
24-
2513
letserverExports;
2614
letwebpackServerMap;
2715
letReactServerDOMServer;
@@ -194,24 +182,33 @@ describe('ReactFlightDOMReplyEdge', () => {
194182
});
195183

196184
it('should support BYOB binary ReadableStreams',async()=>{
197-
constbuffer=newUint8Array([
185+
constsourceBytes=[
198186
123,4,10,5,100,255,244,45,56,67,43,124,67,89,100,20,
199-
]).buffer;
187+
];
188+
189+
// Create separate buffers for each typed array to avoid ArrayBuffer
190+
// transfer issues. Each view needs its own buffer because enqueue()
191+
// transfers ownership.
200192
constbuffers=[
201-
newInt8Array(buffer,1),
202-
newUint8Array(buffer,2),
203-
newUint8ClampedArray(buffer,2),
204-
newInt16Array(buffer,2),
205-
newUint16Array(buffer,2),
206-
newInt32Array(buffer,4),
207-
newUint32Array(buffer,4),
208-
newFloat32Array(buffer,4),
209-
newFloat64Array(buffer,0),
210-
newBigInt64Array(buffer,0),
211-
newBigUint64Array(buffer,0),
212-
newDataView(buffer,3),
193+
newInt8Array(sourceBytes.slice(1)),
194+
newUint8Array(sourceBytes.slice(2)),
195+
newUint8ClampedArray(sourceBytes.slice(2)),
196+
newInt16Array(newUint8Array(sourceBytes.slice(2)).buffer),
197+
newUint16Array(newUint8Array(sourceBytes.slice(2)).buffer),
198+
newInt32Array(newUint8Array(sourceBytes.slice(4)).buffer),
199+
newUint32Array(newUint8Array(sourceBytes.slice(4)).buffer),
200+
newFloat32Array(newUint8Array(sourceBytes.slice(4)).buffer),
201+
newFloat64Array(newUint8Array(sourceBytes.slice(0)).buffer),
202+
newBigInt64Array(newUint8Array(sourceBytes.slice(0)).buffer),
203+
newBigUint64Array(newUint8Array(sourceBytes.slice(0)).buffer),
204+
newDataView(newUint8Array(sourceBytes.slice(3)).buffer),
213205
];
214206

207+
// Save expected bytes before enqueueing (which will detach the buffers).
208+
constexpectedBytes=buffers.flatMap(c=>
209+
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
210+
);
211+
215212
// This a binary stream where each chunk ends up as Uint8Array.
216213
consts=newReadableStream({
217214
type: 'bytes',
@@ -239,11 +236,7 @@ describe('ReactFlightDOMReplyEdge', () => {
239236

240237
// The streamed buffers might be in different chunks and in Uint8Array form but
241238
// the concatenated bytes should be the same.
242-
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(
243-
buffers.flatMap(c=>
244-
Array.from(newUint8Array(c.buffer,c.byteOffset,c.byteLength)),
245-
),
246-
);
239+
expect(streamedBuffers.flatMap(t=>Array.from(t))).toEqual(expectedBytes);
247240
});
248241

249242
it('should abort when parsing an incomplete payload',async()=>{

0 commit comments

Comments
 (0)