Skip to content

Commit 72e2b4b

Browse files
committed
[Flight] Fix detached ArrayBuffer error when streaming typed arrays
When streaming typed arrays, the underlying `ArrayBuffer` may be detached by downstream consumers (e.g. compression streams). This can corrupt the output or cause a "detached ArrayBuffer" error. To avoid this issue, we now clone chunks that are larger than the view size to ensure they have their own backing store. We already did this in the Edge runtime to allow transferring large chunks, and now we also do it in the Node.js runtime to avoid the downstream issues.
1 parent 5f2b571 commit 72e2b4b

File tree

4 files changed

+154
-2
lines changed

4 files changed

+154
-2
lines changed

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ global.ReadableStream =
1515
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
1616
global.WritableStream =
1717
require('web-streams-polyfill/ponyfill/es6').WritableStream;
18+
global.TransformStream =
19+
require('web-streams-polyfill/ponyfill/es6').TransformStream;
1820
global.TextEncoder = require('util').TextEncoder;
1921
global.TextDecoder = require('util').TextDecoder;
2022
global.Blob = require('buffer').Blob;
@@ -796,6 +798,73 @@ describe('ReactFlightDOMEdge', () => {
796798
expect(result).toEqual(buffers);
797799
});
798800

801+
it('handles detached ArrayBuffer during streaming of typed-array views', async () => {
802+
const VIEW_SIZE = 2048;
803+
const length = VIEW_SIZE + 50;
804+
const underlying = new ArrayBuffer(length + 20);
805+
const source = new Uint8Array(underlying, 10, length);
806+
for (let i = 0; i < source.length; i++) source[i] = i % 251;
807+
808+
// Create a copy of the source data to compare against later, because the
809+
// underlying buffer will be detached by the time we read the result.
810+
const expected = source.slice();
811+
812+
const {MessageChannel} = require('worker_threads');
813+
814+
// Detaches an ArrayBuffer by transferring it via MessageChannel
815+
function detachBuffer(arrayBuffer) {
816+
const {port1, port2} = new MessageChannel();
817+
port2.postMessage(arrayBuffer, [arrayBuffer]);
818+
port1.close();
819+
port2.close();
820+
}
821+
822+
// Transform stream that detaches the original buffer on first binary chunk,
823+
// simulating a scenario where the backing store becomes unavailable
824+
// mid-stream.
825+
let detached = false;
826+
const detachingTransformStream = new TransformStream({
827+
transform(chunk, controller) {
828+
try {
829+
if (typeof chunk !== 'string') {
830+
if (!detached) {
831+
detached = true;
832+
detachBuffer(underlying);
833+
}
834+
// Re-wrap the chunk (common downstream pattern) - will throw if
835+
// detached.
836+
controller.enqueue(
837+
new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
838+
);
839+
} else {
840+
controller.enqueue(chunk);
841+
}
842+
} catch (err) {
843+
controller.error(err);
844+
}
845+
},
846+
});
847+
848+
const stream = await serverAct(() =>
849+
passThrough(
850+
ReactServerDOMServer.renderToReadableStream(source).pipeThrough(
851+
detachingTransformStream,
852+
),
853+
),
854+
);
855+
856+
const promise = ReactServerDOMClient.createFromReadableStream(stream, {
857+
serverConsumerManifest: {
858+
moduleMap: {},
859+
moduleLoading: webpackModuleLoading,
860+
},
861+
});
862+
863+
const result = await promise;
864+
865+
expect(expected).toEqual(result);
866+
});
867+
799868
it('should be able to serialize a blob', async () => {
800869
const bytes = new Uint8Array([
801870
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,76 @@ describe('ReactFlightDOMNode', () => {
331331
expect(result).toEqual(buffers);
332332
});
333333

334+
it('handles detached ArrayBuffer during streaming of typed-array views', async () => {
335+
const VIEW_SIZE = 2048;
336+
const length = VIEW_SIZE + 50;
337+
const underlying = new ArrayBuffer(length + 20);
338+
const source = new Uint8Array(underlying, 10, length);
339+
for (let i = 0; i < source.length; i++) source[i] = i % 251;
340+
341+
// Create a copy of the source data to compare against later, because the
342+
// underlying buffer will be detached by the time we read the result.
343+
const expected = source.slice();
344+
345+
const stream = await serverAct(() =>
346+
ReactServerDOMServer.renderToPipeableStream(source),
347+
);
348+
349+
const {MessageChannel} = require('worker_threads');
350+
351+
// Detaches an ArrayBuffer by transferring it via MessageChannel
352+
function detachBuffer(arrayBuffer) {
353+
const {port1, port2} = new MessageChannel();
354+
port2.postMessage(arrayBuffer, [arrayBuffer]);
355+
port1.close();
356+
port2.close();
357+
}
358+
359+
// Transform stream that detaches the original buffer on first binary chunk,
360+
// simulating a scenario where the backing store becomes unavailable
361+
// mid-stream.
362+
const detachingTransform = new Stream.Transform({
363+
...streamOptions,
364+
transform(chunk, _enc, callback) {
365+
try {
366+
if (typeof chunk !== 'string') {
367+
if (!this.detached) {
368+
this.detached = true;
369+
detachBuffer(underlying);
370+
}
371+
// Re-wrap the chunk (common downstream pattern) - will throw if
372+
// detached.
373+
this.push(
374+
new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
375+
);
376+
} else {
377+
this.push(chunk);
378+
}
379+
callback();
380+
} catch (err) {
381+
callback(err);
382+
}
383+
},
384+
});
385+
386+
const readable = new Stream.PassThrough(streamOptions);
387+
const promise = ReactServerDOMClient.createFromNodeStream(readable, {
388+
moduleMap: {},
389+
moduleLoading: webpackModuleLoading,
390+
});
391+
392+
await new Promise((resolve, reject) => {
393+
detachingTransform.on('error', reject);
394+
readable.on('error', reject);
395+
readable.on('finish', resolve);
396+
stream.pipe(detachingTransform).pipe(readable);
397+
});
398+
399+
const result = await promise;
400+
401+
expect(expected).toEqual(result);
402+
});
403+
334404
it('should allow accept a nonce option for Flight preinitialized scripts', async () => {
335405
function ClientComponent() {
336406
return <span>Client Component</span>;

packages/react-server/src/ReactServerStreamConfigEdge.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,10 @@ export function typedArrayToBinaryChunk(
153153
content.byteLength,
154154
);
155155
// We clone large chunks so that we can transfer them when we write them.
156-
// Others get copied into the target buffer.
156+
// Others get copied into the target buffer. This also avoids downstream
157+
// issues where a shared backing ArrayBuffer might be retained, mutated, or
158+
// detached by streams/compression/structured-clone, which can corrupt output
159+
// or trigger “detached ArrayBuffer” errors.
157160
return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer;
158161
}
159162

packages/react-server/src/ReactServerStreamConfigNode.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,17 @@ export function typedArrayToBinaryChunk(
213213
content: $ArrayBufferView,
214214
): BinaryChunk {
215215
// Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays.
216-
return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
216+
const buffer = new Uint8Array(
217+
content.buffer,
218+
content.byteOffset,
219+
content.byteLength,
220+
);
221+
// We clone large chunks so that we can transfer them when we write them.
222+
// Others get copied into the target buffer. This also avoids downstream
223+
// issues where a shared backing ArrayBuffer might be retained, mutated, or
224+
// detached by streams/compression/structured-clone, which can corrupt output
225+
// or trigger “detached ArrayBuffer” errors.
226+
return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer;
217227
}
218228

219229
export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {

0 commit comments

Comments
 (0)