From 59c282be7761734e9bc193b30066ebba38aeea3c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 00:15:41 +0200 Subject: [PATCH 1/6] [FlightReply] Don't drop FormData entries in `decodeReplyFromBusboy` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a regression from #36425 where referenced `FormData` entries can be dropped by `decodeReplyFromBusboy` when files are interleaved with text fields in the payload. `decodeReplyFromBusboy` queues text fields that arrive while a file is being streamed and flushes them after the last file's `'end'`, working around busboy emitting `'end'` deferred relative to subsequent `'field'` events. With multiple files interleaved with text, this loses the relative order of the affected text entries. The reorder was a long-standing but invisible issue — entries came back in the wrong order but were all present — until #36425 tightened how referenced FormData entries are collected from the backing store to rely on them being contiguous. With that assumption violated, referenced FormDatas can now come back with some entries dropped. The pattern is most easily surfaced through `useActionState` actions that return the submitted `FormData` as part of their state. This replaces the tail-flush with a positionally-tagged buffer drained in arrival order. `flush()` walks from a monotonic index pointer, resolving text immediately and files once their `'end'` has fired, and holding later entries when the cursor lands on a still-streaming file. The backing FormData now matches the payload's order, restoring the contiguity assumption (and fixing the long-standing reorder as a side effect). The same change is applied to all five copies in `react-server-dom-{webpack,turbopack,parcel,esm,unbundled}`. Two new tests cover the multi-file interleave. fixes vercel/next.js#93822 --- package.json | 1 + .../src/server/ReactFlightDOMServerNode.js | 89 ++++++++--- .../src/server/ReactFlightDOMServerNode.js | 89 ++++++++--- .../src/server/ReactFlightDOMServerNode.js | 89 ++++++++--- .../src/server/ReactFlightDOMServerNode.js | 89 ++++++++--- .../__tests__/ReactFlightDOMReplyNode-test.js | 149 ++++++++++++++++++ .../src/server/ReactFlightDOMServerNode.js | 89 ++++++++--- yarn.lock | 13 ++ 8 files changed, 478 insertions(+), 130 deletions(-) create mode 100644 packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js diff --git a/package.json b/package.json index 0ae925a1d4ec..b8c230a37132 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "art": "0.10.1", "babel-plugin-syntax-hermes-parser": "^0.32.0", "babel-plugin-syntax-trailing-function-commas": "^6.5.0", + "busboy": "^1.6.0", "chalk": "^3.0.0", "cli-table": "^0.3.1", "coffee-script": "^1.12.7", diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index a4bda173f158..284c3397c88e 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -62,6 +62,7 @@ import { } from 'react-client/src/ReactFlightClientStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -329,6 +330,10 @@ function prerenderToNodeStream( }); } +type BusboyBufferedEntry = + | {kind: 'field', name: string, value: string} + | {kind: 'file', name: string, file: FileHandle, complete: boolean}; + function decodeReplyFromBusboy( busboyStream: Busboy, moduleBasePath: ServerManifest, @@ -344,21 +349,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); + + // Buffer of multipart entries in arrival (payload) order. Files complete + // asynchronously, so we hold any entries that arrived after a still- + // streaming file until that file's 'end' fires. This makes the backing + // FormData's insertion order match the payload's entry order. + const entries: Array = []; + let flushedUpTo = 0; let pendingFiles = 0; - const queuedFields: Array = []; - busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); - } else { - try { - resolveField(response, name, value); - } catch (error) { - busboyStream.destroy(error); + let bodyFinished = false; + let closed = false; + + function flush() { + while (flushedUpTo < entries.length) { + const entry = entries[flushedUpTo]; + if (entry === null) { + flushedUpTo++; + continue; } + if (entry.kind === 'field') { + try { + resolveField(response, entry.name, entry.value); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else if (entry.complete) { + try { + resolveFileComplete(response, entry.name, entry.file); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else { + // This file is still streaming. Hold later entries until it completes + // so the backing FormData reflects payload order. + return; + } + entries[flushedUpTo] = null; + flushedUpTo++; + } + if (bodyFinished && pendingFiles === 0 && !closed) { + closed = true; + close(response); } + } + + busboyStream.on('field', (name, value) => { + entries.push({kind: 'field', name, value}); + flush(); }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -373,27 +412,25 @@ function decodeReplyFromBusboy( } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const entry: BusboyBufferedEntry = { + kind: 'file', + name, + file, + complete: false, + }; + entries.push(entry); value.on('data', chunk => { resolveFileChunk(response, file, chunk); }); value.on('end', () => { - try { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); - } - queuedFields.length = 0; - } - } catch (error) { - busboyStream.destroy(error); - } + entry.complete = true; + pendingFiles--; + flush(); }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index d267edd085af..d1aef327020a 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -75,6 +75,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -560,6 +561,10 @@ export function registerServerActions(manifest: ServerManifest) { serverManifest = manifest; } +type BusboyBufferedEntry = + | {kind: 'field', name: string, value: string} + | {kind: 'file', name: string, file: FileHandle, complete: boolean}; + export function decodeReplyFromBusboy( busboyStream: Busboy, options?: { @@ -574,21 +579,55 @@ export function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); + + // Buffer of multipart entries in arrival (payload) order. Files complete + // asynchronously, so we hold any entries that arrived after a still- + // streaming file until that file's 'end' fires. This makes the backing + // FormData's insertion order match the payload's entry order. + const entries: Array = []; + let flushedUpTo = 0; let pendingFiles = 0; - const queuedFields: Array = []; - busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); - } else { - try { - resolveField(response, name, value); - } catch (error) { - busboyStream.destroy(error); + let bodyFinished = false; + let closed = false; + + function flush() { + while (flushedUpTo < entries.length) { + const entry = entries[flushedUpTo]; + if (entry === null) { + flushedUpTo++; + continue; + } + if (entry.kind === 'field') { + try { + resolveField(response, entry.name, entry.value); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else if (entry.complete) { + try { + resolveFileComplete(response, entry.name, entry.file); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else { + // This file is still streaming. Hold later entries until it completes + // so the backing FormData reflects payload order. + return; } + entries[flushedUpTo] = null; + flushedUpTo++; + } + if (bodyFinished && pendingFiles === 0 && !closed) { + closed = true; + close(response); } + } + + busboyStream.on('field', (name, value) => { + entries.push({kind: 'field', name, value}); + flush(); }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -603,27 +642,25 @@ export function decodeReplyFromBusboy( } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const entry: BusboyBufferedEntry = { + kind: 'file', + name, + file, + complete: false, + }; + entries.push(entry); value.on('data', chunk => { resolveFileChunk(response, file, chunk); }); value.on('end', () => { - try { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); - } - queuedFields.length = 0; - } - } catch (error) { - busboyStream.destroy(error); - } + entry.complete = true; + pendingFiles--; + flush(); }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 5381072b3ad8..ac1fcffdbc51 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -68,6 +68,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -551,6 +552,10 @@ function prerender( }); } +type BusboyBufferedEntry = + | {kind: 'field', name: string, value: string} + | {kind: 'file', name: string, file: FileHandle, complete: boolean}; + function decodeReplyFromBusboy( busboyStream: Busboy, turbopackMap: ServerManifest, @@ -566,21 +571,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); + + // Buffer of multipart entries in arrival (payload) order. Files complete + // asynchronously, so we hold any entries that arrived after a still- + // streaming file until that file's 'end' fires. This makes the backing + // FormData's insertion order match the payload's entry order. + const entries: Array = []; + let flushedUpTo = 0; let pendingFiles = 0; - const queuedFields: Array = []; - busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); - } else { - try { - resolveField(response, name, value); - } catch (error) { - busboyStream.destroy(error); + let bodyFinished = false; + let closed = false; + + function flush() { + while (flushedUpTo < entries.length) { + const entry = entries[flushedUpTo]; + if (entry === null) { + flushedUpTo++; + continue; + } + if (entry.kind === 'field') { + try { + resolveField(response, entry.name, entry.value); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else if (entry.complete) { + try { + resolveFileComplete(response, entry.name, entry.file); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else { + // This file is still streaming. Hold later entries until it completes + // so the backing FormData reflects payload order. + return; } + entries[flushedUpTo] = null; + flushedUpTo++; + } + if (bodyFinished && pendingFiles === 0 && !closed) { + closed = true; + close(response); } + } + + busboyStream.on('field', (name, value) => { + entries.push({kind: 'field', name, value}); + flush(); }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -595,27 +634,25 @@ function decodeReplyFromBusboy( } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const entry: BusboyBufferedEntry = { + kind: 'file', + name, + file, + complete: false, + }; + entries.push(entry); value.on('data', chunk => { resolveFileChunk(response, file, chunk); }); value.on('end', () => { - try { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); - } - queuedFields.length = 0; - } - } catch (error) { - busboyStream.destroy(error); - } + entry.complete = true; + pendingFiles--; + flush(); }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index f118d764447f..08169b693c48 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -68,6 +68,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -551,6 +552,10 @@ function prerender( }); } +type BusboyBufferedEntry = + | {kind: 'field', name: string, value: string} + | {kind: 'file', name: string, file: FileHandle, complete: boolean}; + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -566,21 +571,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); + + // Buffer of multipart entries in arrival (payload) order. Files complete + // asynchronously, so we hold any entries that arrived after a still- + // streaming file until that file's 'end' fires. This makes the backing + // FormData's insertion order match the payload's entry order. + const entries: Array = []; + let flushedUpTo = 0; let pendingFiles = 0; - const queuedFields: Array = []; - busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); - } else { - try { - resolveField(response, name, value); - } catch (error) { - busboyStream.destroy(error); + let bodyFinished = false; + let closed = false; + + function flush() { + while (flushedUpTo < entries.length) { + const entry = entries[flushedUpTo]; + if (entry === null) { + flushedUpTo++; + continue; + } + if (entry.kind === 'field') { + try { + resolveField(response, entry.name, entry.value); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else if (entry.complete) { + try { + resolveFileComplete(response, entry.name, entry.file); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else { + // This file is still streaming. Hold later entries until it completes + // so the backing FormData reflects payload order. + return; } + entries[flushedUpTo] = null; + flushedUpTo++; + } + if (bodyFinished && pendingFiles === 0 && !closed) { + closed = true; + close(response); } + } + + busboyStream.on('field', (name, value) => { + entries.push({kind: 'field', name, value}); + flush(); }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -595,27 +634,25 @@ function decodeReplyFromBusboy( } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const entry: BusboyBufferedEntry = { + kind: 'file', + name, + file, + complete: false, + }; + entries.push(entry); value.on('data', chunk => { resolveFileChunk(response, file, chunk); }); value.on('end', () => { - try { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); - } - queuedFields.length = 0; - } - } catch (error) { - busboyStream.destroy(error); - } + entry.complete = true; + pendingFiles--; + flush(); }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js new file mode 100644 index 000000000000..0924bafbdcd0 --- /dev/null +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js @@ -0,0 +1,149 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +'use strict'; + +let webpackServerMap; +let busboy; +let ReactServerDOMServer; +let ReactServerDOMClient; + +describe('ReactFlightDOMReplyNode', () => { + beforeEach(() => { + jest.resetModules(); + // Simulate the condition resolution + jest.mock('react', () => require('react/react.react-server')); + jest.mock('react-server-dom-webpack/server', () => + require('react-server-dom-webpack/server.node'), + ); + const WebpackMock = require('./utils/WebpackMock'); + webpackServerMap = WebpackMock.webpackServerMap; + ReactServerDOMServer = require('react-server-dom-webpack/server.node'); + jest.resetModules(); + ReactServerDOMClient = require('react-server-dom-webpack/client.node'); + + busboy = require('busboy'); + }); + + // Writes the body to busboy as a multipart stream. Blob entries become + // `filename`-bearing parts so busboy emits them as 'file' events (with + // streamed data) rather than 'field' events. + async function pipeBodyToBusboy(bb, body, boundary) { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [name, value] of body) { + if (typeof value === 'string') { + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"\r\n` + + `\r\n` + + `${value}\r\n`, + ); + } else { + const filename = + typeof value.name === 'string' && value.name !== '' + ? value.name + : 'blob'; + const mimeType = + typeof value.type === 'string' && value.type !== '' + ? value.type + : 'application/octet-stream'; + const buffer = Buffer.from(await value.arrayBuffer()); + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` + + `Content-Type: ${mimeType}\r\n` + + `\r\n`, + ); + bb.write(buffer); + bb.write('\r\n'); + } + } + bb.end(`--${boundary}--\r\n`); + } + + // FormData iterates entries in insertion order per spec, so a referenced + // FormData must round-trip with its entry order intact even when files + // and text fields are interleaved in the payload. + it('preserves entry order when referenced FormDatas interleave files and text', async () => { + const a = new FormData(); + a.append('text_a', 'value_a'); + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt'); + const b = new FormData(); + b.append('text_b', 'value_b'); + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt'); + + const body = await ReactServerDOMClient.encodeReply([a, b]); + const boundary = 'boundary'; + const bb = busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + const reply = ReactServerDOMServer.decodeReplyFromBusboy( + bb, + webpackServerMap, + ); + await pipeBodyToBusboy(bb, body, boundary); + + const result = await reply; + expect(result).toHaveLength(2); + const [decodedA, decodedB] = result; + + const aEntries = Array.from(decodedA.entries()); + expect(aEntries.map(([k]) => k)).toEqual(['text_a', 'file_a']); + expect(aEntries[0][1]).toBe('value_a'); + expect(aEntries[1][1]).toBeInstanceOf(File); + expect(aEntries[1][1].name).toBe('a.txt'); + + const bEntries = Array.from(decodedB.entries()); + expect(bEntries.map(([k]) => k)).toEqual(['text_b', 'file_b']); + expect(bEntries[0][1]).toBe('value_b'); + expect(bEntries[1][1]).toBeInstanceOf(File); + expect(bEntries[1][1].name).toBe('b.txt'); + }); + + // Every entry of a referenced FormData must be present in the decoded + // FormData regardless of where files appear in its iteration order. + it('does not drop entries when referenced FormDatas iterate files before text', async () => { + const a = new FormData(); + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt'); + a.append('text_a', 'value_a'); + const b = new FormData(); + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt'); + b.append('text_b', 'value_b'); + + const body = await ReactServerDOMClient.encodeReply([a, b]); + const boundary = 'boundary'; + const bb = busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + const reply = ReactServerDOMServer.decodeReplyFromBusboy( + bb, + webpackServerMap, + ); + await pipeBodyToBusboy(bb, body, boundary); + + const result = await reply; + expect(result).toHaveLength(2); + const [decodedA, decodedB] = result; + + const aKeys = Array.from(decodedA.keys()).sort(); + expect(aKeys).toEqual(['file_a', 'text_a']); + expect(decodedA.get('text_a')).toBe('value_a'); + expect(decodedA.get('file_a')).toBeInstanceOf(File); + + const bKeys = Array.from(decodedB.keys()).sort(); + expect(bKeys).toEqual(['file_b', 'text_b']); + expect(decodedB.get('text_b')).toBe('value_b'); + expect(decodedB.get('file_b')).toBeInstanceOf(File); + }); +}); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index e710eafd00a1..5d18c123f076 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -68,6 +68,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -551,6 +552,10 @@ function prerender( }); } +type BusboyBufferedEntry = + | {kind: 'field', name: string, value: string} + | {kind: 'file', name: string, file: FileHandle, complete: boolean}; + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -566,21 +571,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); + + // Buffer of multipart entries in arrival (payload) order. Files complete + // asynchronously, so we hold any entries that arrived after a still- + // streaming file until that file's 'end' fires. This makes the backing + // FormData's insertion order match the payload's entry order. + const entries: Array = []; + let flushedUpTo = 0; let pendingFiles = 0; - const queuedFields: Array = []; - busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); - } else { - try { - resolveField(response, name, value); - } catch (error) { - busboyStream.destroy(error); + let bodyFinished = false; + let closed = false; + + function flush() { + while (flushedUpTo < entries.length) { + const entry = entries[flushedUpTo]; + if (entry === null) { + flushedUpTo++; + continue; + } + if (entry.kind === 'field') { + try { + resolveField(response, entry.name, entry.value); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else if (entry.complete) { + try { + resolveFileComplete(response, entry.name, entry.file); + } catch (error) { + busboyStream.destroy(error); + return; + } + } else { + // This file is still streaming. Hold later entries until it completes + // so the backing FormData reflects payload order. + return; } + entries[flushedUpTo] = null; + flushedUpTo++; + } + if (bodyFinished && pendingFiles === 0 && !closed) { + closed = true; + close(response); } + } + + busboyStream.on('field', (name, value) => { + entries.push({kind: 'field', name, value}); + flush(); }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -595,27 +634,25 @@ function decodeReplyFromBusboy( } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const entry: BusboyBufferedEntry = { + kind: 'file', + name, + file, + complete: false, + }; + entries.push(entry); value.on('data', chunk => { resolveFileChunk(response, file, chunk); }); value.on('end', () => { - try { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); - } - queuedFields.length = 0; - } - } catch (error) { - busboyStream.destroy(error); - } + entry.complete = true; + pendingFiles--; + flush(); }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); }); busboyStream.on('error', err => { reportGlobalError( diff --git a/yarn.lock b/yarn.lock index 303fed599cdc..ddcbd689a91b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6092,6 +6092,13 @@ bunyan@1.8.15: mv "~2" safe-json-stringify "~1" +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -8167,6 +8174,7 @@ eslint-plugin-no-unsanitized@4.0.2: "eslint-plugin-react-internal@link:./scripts/eslint-rules": version "0.0.0" + uid "" eslint-plugin-react@^6.7.1: version "6.10.3" @@ -16092,6 +16100,11 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" From dcd8fb881ca54b15df7539bc510067032a78e6da Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 01:25:58 +0200 Subject: [PATCH 2/6] Use file-only buffer --- .../src/server/ReactFlightDOMServerNode.js | 104 +++++++++++------- .../src/server/ReactFlightDOMServerNode.js | 104 +++++++++++------- .../src/server/ReactFlightDOMServerNode.js | 104 +++++++++++------- .../src/server/ReactFlightDOMServerNode.js | 104 +++++++++++------- .../src/server/ReactFlightDOMServerNode.js | 104 +++++++++++------- 5 files changed, 315 insertions(+), 205 deletions(-) diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index 284c3397c88e..556b6bc964dc 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -330,9 +330,15 @@ function prerenderToNodeStream( }); } -type BusboyBufferedEntry = - | {kind: 'field', name: string, value: string} - | {kind: 'file', name: string, file: FileHandle, complete: boolean}; +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, +}; function decodeReplyFromBusboy( busboyStream: Busboy, @@ -350,54 +356,68 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of multipart entries in arrival (payload) order. Files complete - // asynchronously, so we hold any entries that arrived after a still- - // streaming file until that file's 'end' fires. This makes the backing - // FormData's insertion order match the payload's entry order. - const entries: Array = []; + // Buffer of files in arrival (payload) order. Text fields that arrive while a + // file is in flight are queued on the most recent pending file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the buffer is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + // + // We drain by advancing a pointer rather than shifting from the front so the + // total drain stays O(N). + const pendingFiles: Array = []; let flushedUpTo = 0; - let pendingFiles = 0; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < entries.length) { - const entry = entries[flushedUpTo]; - if (entry === null) { - flushedUpTo++; - continue; + while (flushedUpTo < pendingFiles.length) { + const pendingFile = pendingFiles[flushedUpTo]; + if (!pendingFile.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; } - if (entry.kind === 'field') { - try { - resolveField(response, entry.name, entry.value); - } catch (error) { - busboyStream.destroy(error); - return; - } - } else if (entry.complete) { - try { - resolveFileComplete(response, entry.name, entry.file); - } catch (error) { - busboyStream.destroy(error); - return; + try { + resolveFileComplete(response, pendingFile.name, pendingFile.file); + const queuedFields = pendingFile.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } } - } else { - // This file is still streaming. Hold later entries until it completes - // so the backing FormData reflects payload order. + } catch (error) { + busboyStream.destroy(error); return; } - entries[flushedUpTo] = null; flushedUpTo++; } - if (bodyFinished && pendingFiles === 0 && !closed) { + // Fully drained — release the drained wrapper objects for GC instead of + // letting them accumulate until the response promise resolves. + pendingFiles.length = 0; + flushedUpTo = 0; + if (bodyFinished && !closed) { closed = true; close(response); } } busboyStream.on('field', (name, value) => { - entries.push({kind: 'field', name, value}); - flush(); + if (flushedUpTo < pendingFiles.length) { + // A file is in flight; queue the field on the most recent pending file so + // it resolves after that file, preserving payload order. + const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; + if (mostRecentPendingFile.queuedFields === null) { + mostRecentPendingFile.queuedFields = []; + } + mostRecentPendingFile.queuedFields.push(name, value); + } else { + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } + } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -410,21 +430,23 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); - const entry: BusboyBufferedEntry = { - kind: 'file', + const pendingFile: PendingFile = { name, file, complete: false, + queuedFields: null, }; - entries.push(entry); + pendingFiles.push(pendingFile); value.on('data', chunk => { - resolveFileChunk(response, file, chunk); + try { + resolveFileChunk(response, file, chunk); + } catch (error) { + busboyStream.destroy(error); + } }); value.on('end', () => { - entry.complete = true; - pendingFiles--; + pendingFile.complete = true; flush(); }); }); diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index d1aef327020a..cac454517abd 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -561,9 +561,15 @@ export function registerServerActions(manifest: ServerManifest) { serverManifest = manifest; } -type BusboyBufferedEntry = - | {kind: 'field', name: string, value: string} - | {kind: 'file', name: string, file: FileHandle, complete: boolean}; +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, +}; export function decodeReplyFromBusboy( busboyStream: Busboy, @@ -580,54 +586,68 @@ export function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of multipart entries in arrival (payload) order. Files complete - // asynchronously, so we hold any entries that arrived after a still- - // streaming file until that file's 'end' fires. This makes the backing - // FormData's insertion order match the payload's entry order. - const entries: Array = []; + // Buffer of files in arrival (payload) order. Text fields that arrive while a + // file is in flight are queued on the most recent pending file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the buffer is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + // + // We drain by advancing a pointer rather than shifting from the front so the + // total drain stays O(N). + const pendingFiles: Array = []; let flushedUpTo = 0; - let pendingFiles = 0; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < entries.length) { - const entry = entries[flushedUpTo]; - if (entry === null) { - flushedUpTo++; - continue; + while (flushedUpTo < pendingFiles.length) { + const pendingFile = pendingFiles[flushedUpTo]; + if (!pendingFile.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; } - if (entry.kind === 'field') { - try { - resolveField(response, entry.name, entry.value); - } catch (error) { - busboyStream.destroy(error); - return; - } - } else if (entry.complete) { - try { - resolveFileComplete(response, entry.name, entry.file); - } catch (error) { - busboyStream.destroy(error); - return; + try { + resolveFileComplete(response, pendingFile.name, pendingFile.file); + const queuedFields = pendingFile.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } } - } else { - // This file is still streaming. Hold later entries until it completes - // so the backing FormData reflects payload order. + } catch (error) { + busboyStream.destroy(error); return; } - entries[flushedUpTo] = null; flushedUpTo++; } - if (bodyFinished && pendingFiles === 0 && !closed) { + // Fully drained — release the drained wrapper objects for GC instead of + // letting them accumulate until the response promise resolves. + pendingFiles.length = 0; + flushedUpTo = 0; + if (bodyFinished && !closed) { closed = true; close(response); } } busboyStream.on('field', (name, value) => { - entries.push({kind: 'field', name, value}); - flush(); + if (flushedUpTo < pendingFiles.length) { + // A file is in flight; queue the field on the most recent pending file so + // it resolves after that file, preserving payload order. + const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; + if (mostRecentPendingFile.queuedFields === null) { + mostRecentPendingFile.queuedFields = []; + } + mostRecentPendingFile.queuedFields.push(name, value); + } else { + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } + } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -640,21 +660,23 @@ export function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); - const entry: BusboyBufferedEntry = { - kind: 'file', + const pendingFile: PendingFile = { name, file, complete: false, + queuedFields: null, }; - entries.push(entry); + pendingFiles.push(pendingFile); value.on('data', chunk => { - resolveFileChunk(response, file, chunk); + try { + resolveFileChunk(response, file, chunk); + } catch (error) { + busboyStream.destroy(error); + } }); value.on('end', () => { - entry.complete = true; - pendingFiles--; + pendingFile.complete = true; flush(); }); }); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index ac1fcffdbc51..0c60a2167ce1 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -552,9 +552,15 @@ function prerender( }); } -type BusboyBufferedEntry = - | {kind: 'field', name: string, value: string} - | {kind: 'file', name: string, file: FileHandle, complete: boolean}; +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, +}; function decodeReplyFromBusboy( busboyStream: Busboy, @@ -572,54 +578,68 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of multipart entries in arrival (payload) order. Files complete - // asynchronously, so we hold any entries that arrived after a still- - // streaming file until that file's 'end' fires. This makes the backing - // FormData's insertion order match the payload's entry order. - const entries: Array = []; + // Buffer of files in arrival (payload) order. Text fields that arrive while a + // file is in flight are queued on the most recent pending file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the buffer is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + // + // We drain by advancing a pointer rather than shifting from the front so the + // total drain stays O(N). + const pendingFiles: Array = []; let flushedUpTo = 0; - let pendingFiles = 0; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < entries.length) { - const entry = entries[flushedUpTo]; - if (entry === null) { - flushedUpTo++; - continue; + while (flushedUpTo < pendingFiles.length) { + const pendingFile = pendingFiles[flushedUpTo]; + if (!pendingFile.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; } - if (entry.kind === 'field') { - try { - resolveField(response, entry.name, entry.value); - } catch (error) { - busboyStream.destroy(error); - return; - } - } else if (entry.complete) { - try { - resolveFileComplete(response, entry.name, entry.file); - } catch (error) { - busboyStream.destroy(error); - return; + try { + resolveFileComplete(response, pendingFile.name, pendingFile.file); + const queuedFields = pendingFile.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } } - } else { - // This file is still streaming. Hold later entries until it completes - // so the backing FormData reflects payload order. + } catch (error) { + busboyStream.destroy(error); return; } - entries[flushedUpTo] = null; flushedUpTo++; } - if (bodyFinished && pendingFiles === 0 && !closed) { + // Fully drained — release the drained wrapper objects for GC instead of + // letting them accumulate until the response promise resolves. + pendingFiles.length = 0; + flushedUpTo = 0; + if (bodyFinished && !closed) { closed = true; close(response); } } busboyStream.on('field', (name, value) => { - entries.push({kind: 'field', name, value}); - flush(); + if (flushedUpTo < pendingFiles.length) { + // A file is in flight; queue the field on the most recent pending file so + // it resolves after that file, preserving payload order. + const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; + if (mostRecentPendingFile.queuedFields === null) { + mostRecentPendingFile.queuedFields = []; + } + mostRecentPendingFile.queuedFields.push(name, value); + } else { + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } + } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -632,21 +652,23 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); - const entry: BusboyBufferedEntry = { - kind: 'file', + const pendingFile: PendingFile = { name, file, complete: false, + queuedFields: null, }; - entries.push(entry); + pendingFiles.push(pendingFile); value.on('data', chunk => { - resolveFileChunk(response, file, chunk); + try { + resolveFileChunk(response, file, chunk); + } catch (error) { + busboyStream.destroy(error); + } }); value.on('end', () => { - entry.complete = true; - pendingFiles--; + pendingFile.complete = true; flush(); }); }); diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 08169b693c48..6c36ef5b1cd1 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -552,9 +552,15 @@ function prerender( }); } -type BusboyBufferedEntry = - | {kind: 'field', name: string, value: string} - | {kind: 'file', name: string, file: FileHandle, complete: boolean}; +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, +}; function decodeReplyFromBusboy( busboyStream: Busboy, @@ -572,54 +578,68 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of multipart entries in arrival (payload) order. Files complete - // asynchronously, so we hold any entries that arrived after a still- - // streaming file until that file's 'end' fires. This makes the backing - // FormData's insertion order match the payload's entry order. - const entries: Array = []; + // Buffer of files in arrival (payload) order. Text fields that arrive while a + // file is in flight are queued on the most recent pending file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the buffer is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + // + // We drain by advancing a pointer rather than shifting from the front so the + // total drain stays O(N). + const pendingFiles: Array = []; let flushedUpTo = 0; - let pendingFiles = 0; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < entries.length) { - const entry = entries[flushedUpTo]; - if (entry === null) { - flushedUpTo++; - continue; + while (flushedUpTo < pendingFiles.length) { + const pendingFile = pendingFiles[flushedUpTo]; + if (!pendingFile.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; } - if (entry.kind === 'field') { - try { - resolveField(response, entry.name, entry.value); - } catch (error) { - busboyStream.destroy(error); - return; - } - } else if (entry.complete) { - try { - resolveFileComplete(response, entry.name, entry.file); - } catch (error) { - busboyStream.destroy(error); - return; + try { + resolveFileComplete(response, pendingFile.name, pendingFile.file); + const queuedFields = pendingFile.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } } - } else { - // This file is still streaming. Hold later entries until it completes - // so the backing FormData reflects payload order. + } catch (error) { + busboyStream.destroy(error); return; } - entries[flushedUpTo] = null; flushedUpTo++; } - if (bodyFinished && pendingFiles === 0 && !closed) { + // Fully drained — release the drained wrapper objects for GC instead of + // letting them accumulate until the response promise resolves. + pendingFiles.length = 0; + flushedUpTo = 0; + if (bodyFinished && !closed) { closed = true; close(response); } } busboyStream.on('field', (name, value) => { - entries.push({kind: 'field', name, value}); - flush(); + if (flushedUpTo < pendingFiles.length) { + // A file is in flight; queue the field on the most recent pending file so + // it resolves after that file, preserving payload order. + const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; + if (mostRecentPendingFile.queuedFields === null) { + mostRecentPendingFile.queuedFields = []; + } + mostRecentPendingFile.queuedFields.push(name, value); + } else { + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } + } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -632,21 +652,23 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); - const entry: BusboyBufferedEntry = { - kind: 'file', + const pendingFile: PendingFile = { name, file, complete: false, + queuedFields: null, }; - entries.push(entry); + pendingFiles.push(pendingFile); value.on('data', chunk => { - resolveFileChunk(response, file, chunk); + try { + resolveFileChunk(response, file, chunk); + } catch (error) { + busboyStream.destroy(error); + } }); value.on('end', () => { - entry.complete = true; - pendingFiles--; + pendingFile.complete = true; flush(); }); }); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 5d18c123f076..7468514ce3b6 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -552,9 +552,15 @@ function prerender( }); } -type BusboyBufferedEntry = - | {kind: 'field', name: string, value: string} - | {kind: 'file', name: string, file: FileHandle, complete: boolean}; +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, +}; function decodeReplyFromBusboy( busboyStream: Busboy, @@ -572,54 +578,68 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of multipart entries in arrival (payload) order. Files complete - // asynchronously, so we hold any entries that arrived after a still- - // streaming file until that file's 'end' fires. This makes the backing - // FormData's insertion order match the payload's entry order. - const entries: Array = []; + // Buffer of files in arrival (payload) order. Text fields that arrive while a + // file is in flight are queued on the most recent pending file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the buffer is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + // + // We drain by advancing a pointer rather than shifting from the front so the + // total drain stays O(N). + const pendingFiles: Array = []; let flushedUpTo = 0; - let pendingFiles = 0; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < entries.length) { - const entry = entries[flushedUpTo]; - if (entry === null) { - flushedUpTo++; - continue; + while (flushedUpTo < pendingFiles.length) { + const pendingFile = pendingFiles[flushedUpTo]; + if (!pendingFile.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; } - if (entry.kind === 'field') { - try { - resolveField(response, entry.name, entry.value); - } catch (error) { - busboyStream.destroy(error); - return; - } - } else if (entry.complete) { - try { - resolveFileComplete(response, entry.name, entry.file); - } catch (error) { - busboyStream.destroy(error); - return; + try { + resolveFileComplete(response, pendingFile.name, pendingFile.file); + const queuedFields = pendingFile.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } } - } else { - // This file is still streaming. Hold later entries until it completes - // so the backing FormData reflects payload order. + } catch (error) { + busboyStream.destroy(error); return; } - entries[flushedUpTo] = null; flushedUpTo++; } - if (bodyFinished && pendingFiles === 0 && !closed) { + // Fully drained — release the drained wrapper objects for GC instead of + // letting them accumulate until the response promise resolves. + pendingFiles.length = 0; + flushedUpTo = 0; + if (bodyFinished && !closed) { closed = true; close(response); } } busboyStream.on('field', (name, value) => { - entries.push({kind: 'field', name, value}); - flush(); + if (flushedUpTo < pendingFiles.length) { + // A file is in flight; queue the field on the most recent pending file so + // it resolves after that file, preserving payload order. + const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; + if (mostRecentPendingFile.queuedFields === null) { + mostRecentPendingFile.queuedFields = []; + } + mostRecentPendingFile.queuedFields.push(name, value); + } else { + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } + } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { @@ -632,21 +652,23 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); - const entry: BusboyBufferedEntry = { - kind: 'file', + const pendingFile: PendingFile = { name, file, complete: false, + queuedFields: null, }; - entries.push(entry); + pendingFiles.push(pendingFile); value.on('data', chunk => { - resolveFileChunk(response, file, chunk); + try { + resolveFileChunk(response, file, chunk); + } catch (error) { + busboyStream.destroy(error); + } }); value.on('end', () => { - entry.complete = true; - pendingFiles--; + pendingFile.complete = true; flush(); }); }); From 5978b45f65dcce2a0c44fe9d5c1f1e40417fcf0c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 01:29:55 +0200 Subject: [PATCH 3/6] Handle error events --- .../src/server/ReactFlightDOMServerNode.js | 3 +++ .../src/server/ReactFlightDOMServerNode.js | 3 +++ .../src/server/ReactFlightDOMServerNode.js | 3 +++ .../src/server/ReactFlightDOMServerNode.js | 3 +++ .../src/server/ReactFlightDOMServerNode.js | 3 +++ 5 files changed, 15 insertions(+) diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index 556b6bc964dc..5bf3f7790568 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -445,6 +445,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); value.on('end', () => { pendingFile.complete = true; flush(); diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index cac454517abd..b3227930d273 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -675,6 +675,9 @@ export function decodeReplyFromBusboy( busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); value.on('end', () => { pendingFile.complete = true; flush(); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 0c60a2167ce1..e862ad79af72 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -667,6 +667,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); value.on('end', () => { pendingFile.complete = true; flush(); diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 6c36ef5b1cd1..588a7797396b 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -667,6 +667,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); value.on('end', () => { pendingFile.complete = true; flush(); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 7468514ce3b6..093eaa6278a7 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -667,6 +667,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); value.on('end', () => { pendingFile.complete = true; flush(); From 9630baef8660e8d407257b38cbeb61154f5ccdeb Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 17:57:56 +0200 Subject: [PATCH 4/6] Use a linked list --- .../src/server/ReactFlightDOMServerNode.js | 52 +++++++++---------- .../src/server/ReactFlightDOMServerNode.js | 52 +++++++++---------- .../src/server/ReactFlightDOMServerNode.js | 52 +++++++++---------- .../src/server/ReactFlightDOMServerNode.js | 52 +++++++++---------- .../src/server/ReactFlightDOMServerNode.js | 52 +++++++++---------- 5 files changed, 130 insertions(+), 130 deletions(-) diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index 5bf3f7790568..0a5d5e6ef7cb 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -338,6 +338,7 @@ type PendingFile = { // event but before its (deferred) 'end' event. Stored as flat // [name1, value1, name2, value2, ...] pairs. queuedFields: null | Array, + next: null | PendingFile, }; function decodeReplyFromBusboy( @@ -356,31 +357,28 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of files in arrival (payload) order. Text fields that arrive while a - // file is in flight are queued on the most recent pending file's + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's // `queuedFields` so they can be resolved together when that file completes. - // Fields that arrive while the buffer is empty bypass it and resolve + // Fields that arrive while the list is empty bypass it and resolve // immediately. This makes the backing FormData's insertion order match the // payload's entry order. - // - // We drain by advancing a pointer rather than shifting from the front so the - // total drain stays O(N). - const pendingFiles: Array = []; - let flushedUpTo = 0; + let head: null | PendingFile = null; + let tail: null | PendingFile = null; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < pendingFiles.length) { - const pendingFile = pendingFiles[flushedUpTo]; - if (!pendingFile.complete) { + while (head !== null) { + const current = head; + if (!current.complete) { // This file is still streaming. Hold later files and fields until it // completes so the backing FormData reflects payload order. return; } try { - resolveFileComplete(response, pendingFile.name, pendingFile.file); - const queuedFields = pendingFile.queuedFields; + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; if (queuedFields !== null) { for (let i = 0; i < queuedFields.length; i += 2) { resolveField(response, queuedFields[i], queuedFields[i + 1]); @@ -390,12 +388,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); return; } - flushedUpTo++; + head = current.next; } - // Fully drained — release the drained wrapper objects for GC instead of - // letting them accumulate until the response promise resolves. - pendingFiles.length = 0; - flushedUpTo = 0; + tail = null; if (bodyFinished && !closed) { closed = true; close(response); @@ -403,14 +398,13 @@ function decodeReplyFromBusboy( } busboyStream.on('field', (name, value) => { - if (flushedUpTo < pendingFiles.length) { - // A file is in flight; queue the field on the most recent pending file so - // it resolves after that file, preserving payload order. - const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; - if (mostRecentPendingFile.queuedFields === null) { - mostRecentPendingFile.queuedFields = []; + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; } - mostRecentPendingFile.queuedFields.push(name, value); + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -436,8 +430,14 @@ function decodeReplyFromBusboy( file, complete: false, queuedFields: null, + next: null, }; - pendingFiles.push(pendingFile); + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { try { resolveFileChunk(response, file, chunk); diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index b3227930d273..63480c24b5fc 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -569,6 +569,7 @@ type PendingFile = { // event but before its (deferred) 'end' event. Stored as flat // [name1, value1, name2, value2, ...] pairs. queuedFields: null | Array, + next: null | PendingFile, }; export function decodeReplyFromBusboy( @@ -586,31 +587,28 @@ export function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of files in arrival (payload) order. Text fields that arrive while a - // file is in flight are queued on the most recent pending file's + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's // `queuedFields` so they can be resolved together when that file completes. - // Fields that arrive while the buffer is empty bypass it and resolve + // Fields that arrive while the list is empty bypass it and resolve // immediately. This makes the backing FormData's insertion order match the // payload's entry order. - // - // We drain by advancing a pointer rather than shifting from the front so the - // total drain stays O(N). - const pendingFiles: Array = []; - let flushedUpTo = 0; + let head: null | PendingFile = null; + let tail: null | PendingFile = null; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < pendingFiles.length) { - const pendingFile = pendingFiles[flushedUpTo]; - if (!pendingFile.complete) { + while (head !== null) { + const current = head; + if (!current.complete) { // This file is still streaming. Hold later files and fields until it // completes so the backing FormData reflects payload order. return; } try { - resolveFileComplete(response, pendingFile.name, pendingFile.file); - const queuedFields = pendingFile.queuedFields; + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; if (queuedFields !== null) { for (let i = 0; i < queuedFields.length; i += 2) { resolveField(response, queuedFields[i], queuedFields[i + 1]); @@ -620,12 +618,9 @@ export function decodeReplyFromBusboy( busboyStream.destroy(error); return; } - flushedUpTo++; + head = current.next; } - // Fully drained — release the drained wrapper objects for GC instead of - // letting them accumulate until the response promise resolves. - pendingFiles.length = 0; - flushedUpTo = 0; + tail = null; if (bodyFinished && !closed) { closed = true; close(response); @@ -633,14 +628,13 @@ export function decodeReplyFromBusboy( } busboyStream.on('field', (name, value) => { - if (flushedUpTo < pendingFiles.length) { - // A file is in flight; queue the field on the most recent pending file so - // it resolves after that file, preserving payload order. - const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; - if (mostRecentPendingFile.queuedFields === null) { - mostRecentPendingFile.queuedFields = []; + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; } - mostRecentPendingFile.queuedFields.push(name, value); + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -666,8 +660,14 @@ export function decodeReplyFromBusboy( file, complete: false, queuedFields: null, + next: null, }; - pendingFiles.push(pendingFile); + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { try { resolveFileChunk(response, file, chunk); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index e862ad79af72..5b8a2c5eba91 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -560,6 +560,7 @@ type PendingFile = { // event but before its (deferred) 'end' event. Stored as flat // [name1, value1, name2, value2, ...] pairs. queuedFields: null | Array, + next: null | PendingFile, }; function decodeReplyFromBusboy( @@ -578,31 +579,28 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of files in arrival (payload) order. Text fields that arrive while a - // file is in flight are queued on the most recent pending file's + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's // `queuedFields` so they can be resolved together when that file completes. - // Fields that arrive while the buffer is empty bypass it and resolve + // Fields that arrive while the list is empty bypass it and resolve // immediately. This makes the backing FormData's insertion order match the // payload's entry order. - // - // We drain by advancing a pointer rather than shifting from the front so the - // total drain stays O(N). - const pendingFiles: Array = []; - let flushedUpTo = 0; + let head: null | PendingFile = null; + let tail: null | PendingFile = null; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < pendingFiles.length) { - const pendingFile = pendingFiles[flushedUpTo]; - if (!pendingFile.complete) { + while (head !== null) { + const current = head; + if (!current.complete) { // This file is still streaming. Hold later files and fields until it // completes so the backing FormData reflects payload order. return; } try { - resolveFileComplete(response, pendingFile.name, pendingFile.file); - const queuedFields = pendingFile.queuedFields; + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; if (queuedFields !== null) { for (let i = 0; i < queuedFields.length; i += 2) { resolveField(response, queuedFields[i], queuedFields[i + 1]); @@ -612,12 +610,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); return; } - flushedUpTo++; + head = current.next; } - // Fully drained — release the drained wrapper objects for GC instead of - // letting them accumulate until the response promise resolves. - pendingFiles.length = 0; - flushedUpTo = 0; + tail = null; if (bodyFinished && !closed) { closed = true; close(response); @@ -625,14 +620,13 @@ function decodeReplyFromBusboy( } busboyStream.on('field', (name, value) => { - if (flushedUpTo < pendingFiles.length) { - // A file is in flight; queue the field on the most recent pending file so - // it resolves after that file, preserving payload order. - const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; - if (mostRecentPendingFile.queuedFields === null) { - mostRecentPendingFile.queuedFields = []; + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; } - mostRecentPendingFile.queuedFields.push(name, value); + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -658,8 +652,14 @@ function decodeReplyFromBusboy( file, complete: false, queuedFields: null, + next: null, }; - pendingFiles.push(pendingFile); + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { try { resolveFileChunk(response, file, chunk); diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 588a7797396b..355827873c41 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -560,6 +560,7 @@ type PendingFile = { // event but before its (deferred) 'end' event. Stored as flat // [name1, value1, name2, value2, ...] pairs. queuedFields: null | Array, + next: null | PendingFile, }; function decodeReplyFromBusboy( @@ -578,31 +579,28 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of files in arrival (payload) order. Text fields that arrive while a - // file is in flight are queued on the most recent pending file's + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's // `queuedFields` so they can be resolved together when that file completes. - // Fields that arrive while the buffer is empty bypass it and resolve + // Fields that arrive while the list is empty bypass it and resolve // immediately. This makes the backing FormData's insertion order match the // payload's entry order. - // - // We drain by advancing a pointer rather than shifting from the front so the - // total drain stays O(N). - const pendingFiles: Array = []; - let flushedUpTo = 0; + let head: null | PendingFile = null; + let tail: null | PendingFile = null; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < pendingFiles.length) { - const pendingFile = pendingFiles[flushedUpTo]; - if (!pendingFile.complete) { + while (head !== null) { + const current = head; + if (!current.complete) { // This file is still streaming. Hold later files and fields until it // completes so the backing FormData reflects payload order. return; } try { - resolveFileComplete(response, pendingFile.name, pendingFile.file); - const queuedFields = pendingFile.queuedFields; + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; if (queuedFields !== null) { for (let i = 0; i < queuedFields.length; i += 2) { resolveField(response, queuedFields[i], queuedFields[i + 1]); @@ -612,12 +610,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); return; } - flushedUpTo++; + head = current.next; } - // Fully drained — release the drained wrapper objects for GC instead of - // letting them accumulate until the response promise resolves. - pendingFiles.length = 0; - flushedUpTo = 0; + tail = null; if (bodyFinished && !closed) { closed = true; close(response); @@ -625,14 +620,13 @@ function decodeReplyFromBusboy( } busboyStream.on('field', (name, value) => { - if (flushedUpTo < pendingFiles.length) { - // A file is in flight; queue the field on the most recent pending file so - // it resolves after that file, preserving payload order. - const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; - if (mostRecentPendingFile.queuedFields === null) { - mostRecentPendingFile.queuedFields = []; + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; } - mostRecentPendingFile.queuedFields.push(name, value); + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -658,8 +652,14 @@ function decodeReplyFromBusboy( file, complete: false, queuedFields: null, + next: null, }; - pendingFiles.push(pendingFile); + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { try { resolveFileChunk(response, file, chunk); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 093eaa6278a7..d41ce414dd38 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -560,6 +560,7 @@ type PendingFile = { // event but before its (deferred) 'end' event. Stored as flat // [name1, value1, name2, value2, ...] pairs. queuedFields: null | Array, + next: null | PendingFile, }; function decodeReplyFromBusboy( @@ -578,31 +579,28 @@ function decodeReplyFromBusboy( options ? options.arraySizeLimit : undefined, ); - // Buffer of files in arrival (payload) order. Text fields that arrive while a - // file is in flight are queued on the most recent pending file's + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's // `queuedFields` so they can be resolved together when that file completes. - // Fields that arrive while the buffer is empty bypass it and resolve + // Fields that arrive while the list is empty bypass it and resolve // immediately. This makes the backing FormData's insertion order match the // payload's entry order. - // - // We drain by advancing a pointer rather than shifting from the front so the - // total drain stays O(N). - const pendingFiles: Array = []; - let flushedUpTo = 0; + let head: null | PendingFile = null; + let tail: null | PendingFile = null; let bodyFinished = false; let closed = false; function flush() { - while (flushedUpTo < pendingFiles.length) { - const pendingFile = pendingFiles[flushedUpTo]; - if (!pendingFile.complete) { + while (head !== null) { + const current = head; + if (!current.complete) { // This file is still streaming. Hold later files and fields until it // completes so the backing FormData reflects payload order. return; } try { - resolveFileComplete(response, pendingFile.name, pendingFile.file); - const queuedFields = pendingFile.queuedFields; + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; if (queuedFields !== null) { for (let i = 0; i < queuedFields.length; i += 2) { resolveField(response, queuedFields[i], queuedFields[i + 1]); @@ -612,12 +610,9 @@ function decodeReplyFromBusboy( busboyStream.destroy(error); return; } - flushedUpTo++; + head = current.next; } - // Fully drained — release the drained wrapper objects for GC instead of - // letting them accumulate until the response promise resolves. - pendingFiles.length = 0; - flushedUpTo = 0; + tail = null; if (bodyFinished && !closed) { closed = true; close(response); @@ -625,14 +620,13 @@ function decodeReplyFromBusboy( } busboyStream.on('field', (name, value) => { - if (flushedUpTo < pendingFiles.length) { - // A file is in flight; queue the field on the most recent pending file so - // it resolves after that file, preserving payload order. - const mostRecentPendingFile = pendingFiles[pendingFiles.length - 1]; - if (mostRecentPendingFile.queuedFields === null) { - mostRecentPendingFile.queuedFields = []; + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; } - mostRecentPendingFile.queuedFields.push(name, value); + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -658,8 +652,14 @@ function decodeReplyFromBusboy( file, complete: false, queuedFields: null, + next: null, }; - pendingFiles.push(pendingFile); + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { try { resolveFileChunk(response, file, chunk); From 95599c935a1bd1ddb33a8835d1496a5293c73a8c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 19:13:23 +0200 Subject: [PATCH 5/6] Throw when not fully closed after flush on finish --- .../src/server/ReactFlightDOMServerNode.js | 7 +++++++ .../src/server/ReactFlightDOMServerNode.js | 7 +++++++ .../src/server/ReactFlightDOMServerNode.js | 7 +++++++ .../src/server/ReactFlightDOMServerNode.js | 7 +++++++ .../src/server/ReactFlightDOMServerNode.js | 7 +++++++ 5 files changed, 35 insertions(+) diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index 0a5d5e6ef7cb..b39cda26d905 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -456,6 +456,13 @@ function decodeReplyFromBusboy( busboyStream.on('finish', () => { bodyFinished = true; flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + busboyStream.destroy( + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index 63480c24b5fc..fe5e5e91a740 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -686,6 +686,13 @@ export function decodeReplyFromBusboy( busboyStream.on('finish', () => { bodyFinished = true; flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + busboyStream.destroy( + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 5b8a2c5eba91..217f1f904102 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -678,6 +678,13 @@ function decodeReplyFromBusboy( busboyStream.on('finish', () => { bodyFinished = true; flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + busboyStream.destroy( + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 355827873c41..2977b458699e 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -678,6 +678,13 @@ function decodeReplyFromBusboy( busboyStream.on('finish', () => { bodyFinished = true; flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + busboyStream.destroy( + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index d41ce414dd38..975db3aba854 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -678,6 +678,13 @@ function decodeReplyFromBusboy( busboyStream.on('finish', () => { bodyFinished = true; flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + busboyStream.destroy( + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( From 77b34cc5893ae9489959fdb1bf2a3f17a15dd964 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 21:52:28 +0200 Subject: [PATCH 6/6] Call `reportGlobalError` directly --- .../src/server/ReactFlightDOMServerNode.js | 3 ++- .../src/server/ReactFlightDOMServerNode.js | 3 ++- .../src/server/ReactFlightDOMServerNode.js | 3 ++- .../src/server/ReactFlightDOMServerNode.js | 3 ++- .../src/server/ReactFlightDOMServerNode.js | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index b39cda26d905..0224bb382bfa 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -459,7 +459,8 @@ function decodeReplyFromBusboy( if (!closed) { // Invariant: busboy delays 'finish' until every file's 'end' event has // fired, so the flush above should always close the response. - busboyStream.destroy( + reportGlobalError( + response, new Error('Reply finished with incomplete file part.'), ); } diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index fe5e5e91a740..d39081aecfef 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -689,7 +689,8 @@ export function decodeReplyFromBusboy( if (!closed) { // Invariant: busboy delays 'finish' until every file's 'end' event has // fired, so the flush above should always close the response. - busboyStream.destroy( + reportGlobalError( + response, new Error('Reply finished with incomplete file part.'), ); } diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 217f1f904102..6c8d759a99ac 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -681,7 +681,8 @@ function decodeReplyFromBusboy( if (!closed) { // Invariant: busboy delays 'finish' until every file's 'end' event has // fired, so the flush above should always close the response. - busboyStream.destroy( + reportGlobalError( + response, new Error('Reply finished with incomplete file part.'), ); } diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 2977b458699e..eac2f9c48317 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -681,7 +681,8 @@ function decodeReplyFromBusboy( if (!closed) { // Invariant: busboy delays 'finish' until every file's 'end' event has // fired, so the flush above should always close the response. - busboyStream.destroy( + reportGlobalError( + response, new Error('Reply finished with incomplete file part.'), ); } diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 975db3aba854..c8809b040460 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -681,7 +681,8 @@ function decodeReplyFromBusboy( if (!closed) { // Invariant: busboy delays 'finish' until every file's 'end' event has // fired, so the flush above should always close the response. - busboyStream.destroy( + reportGlobalError( + response, new Error('Reply finished with incomplete file part.'), ); }