@samitouri / QOS-React-1 / commits / b91823e214

[FlightReply] Don't drop FormData entries in `decodeReplyFromBusboy` (#36468)

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 linked list of pending files. Text fields that arrive while a file is in flight are queued on the tail file's `queuedFields`; fields that arrive when the list is empty resolve immediately. `flush()` walks from the head, resolving each completed file followed by its queued fields, and stops at the first file that hasn't ended yet. 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

Hendrik Liebau committed May 14, 2026 at 22:03 UTC b91823e21434ac665450e67ddc6f816710255938
8 files changed +618 -105
package.json
+1
@@ -53,6 +53,7 @@
53 "art": "0.10.1",
54 "babel-plugin-syntax-hermes-parser": "^0.32.0",
55 "babel-plugin-syntax-trailing-function-commas": "^6.5.0",
56 + "busboy": "^1.6.0",
57 "chalk": "^3.0.0",
58 "cli-table": "^0.3.1",
59 "coffee-script": "^1.12.7",
packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js
+91 -21
@@ -62,6 +62,7 @@ import {
62 } from 'react-client/src/ReactFlightClientStreamConfigNode';
63
64 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
65 +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
66
67 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
68
@@ -329,6 +330,17 @@ function prerenderToNodeStream(
330 });
331 }
332
333 +type PendingFile = {
334 + name: string,
335 + file: FileHandle,
336 + complete: boolean,
337 + // Lazily allocated when a text field arrives after this file's 'file'
338 + // event but before its (deferred) 'end' event. Stored as flat
339 + // [name1, value1, name2, value2, ...] pairs.
340 + queuedFields: null | Array<string>,
341 + next: null | PendingFile,
342 +};
343 +
344 function decodeReplyFromBusboy<T>(
345 busboyStream: Busboy,
346 moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
356 undefined,
357 options ? options.arraySizeLimit : undefined,
358 );
347 - let pendingFiles = 0;
348 - const queuedFields: Array<string> = [];
359 +
360 + // Linked list of pending files in arrival (payload) order. Text fields that
361 + // arrive while a file is in flight are queued on the tail file's
362 + // `queuedFields` so they can be resolved together when that file completes.
363 + // Fields that arrive while the list is empty bypass it and resolve
364 + // immediately. This makes the backing FormData's insertion order match the
365 + // payload's entry order.
366 + let head: null | PendingFile = null;
367 + let tail: null | PendingFile = null;
368 + let bodyFinished = false;
369 + let closed = false;
370 +
371 + function flush() {
372 + while (head !== null) {
373 + const current = head;
374 + if (!current.complete) {
375 + // This file is still streaming. Hold later files and fields until it
376 + // completes so the backing FormData reflects payload order.
377 + return;
378 + }
379 + try {
380 + resolveFileComplete(response, current.name, current.file);
381 + const queuedFields = current.queuedFields;
382 + if (queuedFields !== null) {
383 + for (let i = 0; i < queuedFields.length; i += 2) {
384 + resolveField(response, queuedFields[i], queuedFields[i + 1]);
385 + }
386 + }
387 + } catch (error) {
388 + busboyStream.destroy(error);
389 + return;
390 + }
391 + head = current.next;
392 + }
393 + tail = null;
394 + if (bodyFinished && !closed) {
395 + closed = true;
396 + close(response);
397 + }
398 + }
399 +
400 busboyStream.on('field', (name, value) => {
350 - if (pendingFiles > 0) {
351 - // Because the 'end' event fires two microtasks after the next 'field'
352 - // we would resolve files and fields out of order. To handle this properly
353 - // we queue any fields we receive until the previous file is done.
354 - queuedFields.push(name, value);
401 + if (tail !== null) {
402 + // A file is in flight; queue the field on the tail (most recent) pending
403 + // file so it resolves after that file, preserving payload order.
404 + if (tail.queuedFields === null) {
405 + tail.queuedFields = [];
406 + }
407 + tail.queuedFields.push(name, value);
408 } else {
409 try {
410 resolveField(response, name, value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
424 );
425 return;
426 }
374 - pendingFiles++;
427 const file = resolveFileInfo(response, name, filename, mimeType);
428 + const pendingFile: PendingFile = {
429 + name,
430 + file,
431 + complete: false,
432 + queuedFields: null,
433 + next: null,
434 + };
435 + if (tail === null) {
436 + head = pendingFile;
437 + } else {
438 + tail.next = pendingFile;
439 + }
440 + tail = pendingFile;
441 value.on('data', chunk => {
377 - resolveFileChunk(response, file, chunk);
378 - });
379 - value.on('end', () => {
442 try {
381 - resolveFileComplete(response, name, file);
382 - pendingFiles--;
383 - if (pendingFiles === 0) {
384 - // Release any queued fields
385 - for (let i = 0; i < queuedFields.length; i += 2) {
386 - resolveField(response, queuedFields[i], queuedFields[i + 1]);
387 - }
388 - queuedFields.length = 0;
389 - }
443 + resolveFileChunk(response, file, chunk);
444 } catch (error) {
445 busboyStream.destroy(error);
446 }
447 });
448 + value.on('error', error => {
449 + busboyStream.destroy(error);
450 + });
451 + value.on('end', () => {
452 + pendingFile.complete = true;
453 + flush();
454 + });
455 });
456 busboyStream.on('finish', () => {
396 - close(response);
457 + bodyFinished = true;
458 + flush();
459 + if (!closed) {
460 + // Invariant: busboy delays 'finish' until every file's 'end' event has
461 + // fired, so the flush above should always close the response.
462 + reportGlobalError(
463 + response,
464 + new Error('Reply finished with incomplete file part.'),
465 + );
466 + }
467 });
468 busboyStream.on('error', err => {
469 reportGlobalError(
packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js
+91 -21
@@ -75,6 +75,7 @@ import {
75 import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
76
77 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
78 +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
79
80 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
81
@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
561 serverManifest = manifest;
562 }
563
564 +type PendingFile = {
565 + name: string,
566 + file: FileHandle,
567 + complete: boolean,
568 + // Lazily allocated when a text field arrives after this file's 'file'
569 + // event but before its (deferred) 'end' event. Stored as flat
570 + // [name1, value1, name2, value2, ...] pairs.
571 + queuedFields: null | Array<string>,
572 + next: null | PendingFile,
573 +};
574 +
575 export function decodeReplyFromBusboy<T>(
576 busboyStream: Busboy,
577 options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
586 undefined,
587 options ? options.arraySizeLimit : undefined,
588 );
577 - let pendingFiles = 0;
578 - const queuedFields: Array<string> = [];
589 +
590 + // Linked list of pending files in arrival (payload) order. Text fields that
591 + // arrive while a file is in flight are queued on the tail file's
592 + // `queuedFields` so they can be resolved together when that file completes.
593 + // Fields that arrive while the list is empty bypass it and resolve
594 + // immediately. This makes the backing FormData's insertion order match the
595 + // payload's entry order.
596 + let head: null | PendingFile = null;
597 + let tail: null | PendingFile = null;
598 + let bodyFinished = false;
599 + let closed = false;
600 +
601 + function flush() {
602 + while (head !== null) {
603 + const current = head;
604 + if (!current.complete) {
605 + // This file is still streaming. Hold later files and fields until it
606 + // completes so the backing FormData reflects payload order.
607 + return;
608 + }
609 + try {
610 + resolveFileComplete(response, current.name, current.file);
611 + const queuedFields = current.queuedFields;
612 + if (queuedFields !== null) {
613 + for (let i = 0; i < queuedFields.length; i += 2) {
614 + resolveField(response, queuedFields[i], queuedFields[i + 1]);
615 + }
616 + }
617 + } catch (error) {
618 + busboyStream.destroy(error);
619 + return;
620 + }
621 + head = current.next;
622 + }
623 + tail = null;
624 + if (bodyFinished && !closed) {
625 + closed = true;
626 + close(response);
627 + }
628 + }
629 +
630 busboyStream.on('field', (name, value) => {
580 - if (pendingFiles > 0) {
581 - // Because the 'end' event fires two microtasks after the next 'field'
582 - // we would resolve files and fields out of order. To handle this properly
583 - // we queue any fields we receive until the previous file is done.
584 - queuedFields.push(name, value);
631 + if (tail !== null) {
632 + // A file is in flight; queue the field on the tail (most recent) pending
633 + // file so it resolves after that file, preserving payload order.
634 + if (tail.queuedFields === null) {
635 + tail.queuedFields = [];
636 + }
637 + tail.queuedFields.push(name, value);
638 } else {
639 try {
640 resolveField(response, name, value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
654 );
655 return;
656 }
604 - pendingFiles++;
657 const file = resolveFileInfo(response, name, filename, mimeType);
658 + const pendingFile: PendingFile = {
659 + name,
660 + file,
661 + complete: false,
662 + queuedFields: null,
663 + next: null,
664 + };
665 + if (tail === null) {
666 + head = pendingFile;
667 + } else {
668 + tail.next = pendingFile;
669 + }
670 + tail = pendingFile;
671 value.on('data', chunk => {
607 - resolveFileChunk(response, file, chunk);
608 - });
609 - value.on('end', () => {
672 try {
611 - resolveFileComplete(response, name, file);
612 - pendingFiles--;
613 - if (pendingFiles === 0) {
614 - // Release any queued fields
615 - for (let i = 0; i < queuedFields.length; i += 2) {
616 - resolveField(response, queuedFields[i], queuedFields[i + 1]);
617 - }
618 - queuedFields.length = 0;
619 - }
673 + resolveFileChunk(response, file, chunk);
674 } catch (error) {
675 busboyStream.destroy(error);
676 }
677 });
678 + value.on('error', error => {
679 + busboyStream.destroy(error);
680 + });
681 + value.on('end', () => {
682 + pendingFile.complete = true;
683 + flush();
684 + });
685 });
686 busboyStream.on('finish', () => {
626 - close(response);
687 + bodyFinished = true;
688 + flush();
689 + if (!closed) {
690 + // Invariant: busboy delays 'finish' until every file's 'end' event has
691 + // fired, so the flush above should always close the response.
692 + reportGlobalError(
693 + response,
694 + new Error('Reply finished with incomplete file part.'),
695 + );
696 + }
697 });
698 busboyStream.on('error', err => {
699 reportGlobalError(
packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js
+91 -21
@@ -68,6 +68,7 @@ import {
68 import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
69
70 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
71 +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
72
73 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
74
@@ -551,6 +552,17 @@ function prerender(
552 });
553 }
554
555 +type PendingFile = {
556 + name: string,
557 + file: FileHandle,
558 + complete: boolean,
559 + // Lazily allocated when a text field arrives after this file's 'file'
560 + // event but before its (deferred) 'end' event. Stored as flat
561 + // [name1, value1, name2, value2, ...] pairs.
562 + queuedFields: null | Array<string>,
563 + next: null | PendingFile,
564 +};
565 +
566 function decodeReplyFromBusboy<T>(
567 busboyStream: Busboy,
568 turbopackMap: ServerManifest,
@@ -566,14 +578,55 @@ function decodeReplyFromBusboy<T>(
578 undefined,
579 options ? options.arraySizeLimit : undefined,
580 );
569 - let pendingFiles = 0;
570 - const queuedFields: Array<string> = [];
581 +
582 + // Linked list of pending files in arrival (payload) order. Text fields that
583 + // arrive while a file is in flight are queued on the tail file's
584 + // `queuedFields` so they can be resolved together when that file completes.
585 + // Fields that arrive while the list is empty bypass it and resolve
586 + // immediately. This makes the backing FormData's insertion order match the
587 + // payload's entry order.
588 + let head: null | PendingFile = null;
589 + let tail: null | PendingFile = null;
590 + let bodyFinished = false;
591 + let closed = false;
592 +
593 + function flush() {
594 + while (head !== null) {
595 + const current = head;
596 + if (!current.complete) {
597 + // This file is still streaming. Hold later files and fields until it
598 + // completes so the backing FormData reflects payload order.
599 + return;
600 + }
601 + try {
602 + resolveFileComplete(response, current.name, current.file);
603 + const queuedFields = current.queuedFields;
604 + if (queuedFields !== null) {
605 + for (let i = 0; i < queuedFields.length; i += 2) {
606 + resolveField(response, queuedFields[i], queuedFields[i + 1]);
607 + }
608 + }
609 + } catch (error) {
610 + busboyStream.destroy(error);
611 + return;
612 + }
613 + head = current.next;
614 + }
615 + tail = null;
616 + if (bodyFinished && !closed) {
617 + closed = true;
618 + close(response);
619 + }
620 + }
621 +
622 busboyStream.on('field', (name, value) => {
572 - if (pendingFiles > 0) {
573 - // Because the 'end' event fires two microtasks after the next 'field'
574 - // we would resolve files and fields out of order. To handle this properly
575 - // we queue any fields we receive until the previous file is done.
576 - queuedFields.push(name, value);
623 + if (tail !== null) {
624 + // A file is in flight; queue the field on the tail (most recent) pending
625 + // file so it resolves after that file, preserving payload order.
626 + if (tail.queuedFields === null) {
627 + tail.queuedFields = [];
628 + }
629 + tail.queuedFields.push(name, value);
630 } else {
631 try {
632 resolveField(response, name, value);
@@ -593,29 +646,46 @@ function decodeReplyFromBusboy<T>(
646 );
647 return;
648 }
596 - pendingFiles++;
649 const file = resolveFileInfo(response, name, filename, mimeType);
650 + const pendingFile: PendingFile = {
651 + name,
652 + file,
653 + complete: false,
654 + queuedFields: null,
655 + next: null,
656 + };
657 + if (tail === null) {
658 + head = pendingFile;
659 + } else {
660 + tail.next = pendingFile;
661 + }
662 + tail = pendingFile;
663 value.on('data', chunk => {
599 - resolveFileChunk(response, file, chunk);
600 - });
601 - value.on('end', () => {
664 try {
603 - resolveFileComplete(response, name, file);
604 - pendingFiles--;
605 - if (pendingFiles === 0) {
606 - // Release any queued fields
607 - for (let i = 0; i < queuedFields.length; i += 2) {
608 - resolveField(response, queuedFields[i], queuedFields[i + 1]);
609 - }
610 - queuedFields.length = 0;
611 - }
665 + resolveFileChunk(response, file, chunk);
666 } catch (error) {
667 busboyStream.destroy(error);
668 }
669 });
670 + value.on('error', error => {
671 + busboyStream.destroy(error);
672 + });
673 + value.on('end', () => {
674 + pendingFile.complete = true;
675 + flush();
676 + });
677 });
678 busboyStream.on('finish', () => {
618 - close(response);
679 + bodyFinished = true;
680 + flush();
681 + if (!closed) {
682 + // Invariant: busboy delays 'finish' until every file's 'end' event has
683 + // fired, so the flush above should always close the response.
684 + reportGlobalError(
685 + response,
686 + new Error('Reply finished with incomplete file part.'),
687 + );
688 + }
689 });
690 busboyStream.on('error', err => {
691 reportGlobalError(
packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js
+91 -21
@@ -68,6 +68,7 @@ import {
68 import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
69
70 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
71 +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
72
73 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
74
@@ -551,6 +552,17 @@ function prerender(
552 });
553 }
554
555 +type PendingFile = {
556 + name: string,
557 + file: FileHandle,
558 + complete: boolean,
559 + // Lazily allocated when a text field arrives after this file's 'file'
560 + // event but before its (deferred) 'end' event. Stored as flat
561 + // [name1, value1, name2, value2, ...] pairs.
562 + queuedFields: null | Array<string>,
563 + next: null | PendingFile,
564 +};
565 +
566 function decodeReplyFromBusboy<T>(
567 busboyStream: Busboy,
568 webpackMap: ServerManifest,
@@ -566,14 +578,55 @@ function decodeReplyFromBusboy<T>(
578 undefined,
579 options ? options.arraySizeLimit : undefined,
580 );
569 - let pendingFiles = 0;
570 - const queuedFields: Array<string> = [];
581 +
582 + // Linked list of pending files in arrival (payload) order. Text fields that
583 + // arrive while a file is in flight are queued on the tail file's
584 + // `queuedFields` so they can be resolved together when that file completes.
585 + // Fields that arrive while the list is empty bypass it and resolve
586 + // immediately. This makes the backing FormData's insertion order match the
587 + // payload's entry order.
588 + let head: null | PendingFile = null;
589 + let tail: null | PendingFile = null;
590 + let bodyFinished = false;
591 + let closed = false;
592 +
593 + function flush() {
594 + while (head !== null) {
595 + const current = head;
596 + if (!current.complete) {
597 + // This file is still streaming. Hold later files and fields until it
598 + // completes so the backing FormData reflects payload order.
599 + return;
600 + }
601 + try {
602 + resolveFileComplete(response, current.name, current.file);
603 + const queuedFields = current.queuedFields;
604 + if (queuedFields !== null) {
605 + for (let i = 0; i < queuedFields.length; i += 2) {
606 + resolveField(response, queuedFields[i], queuedFields[i + 1]);
607 + }
608 + }
609 + } catch (error) {
610 + busboyStream.destroy(error);
611 + return;
612 + }
613 + head = current.next;
614 + }
615 + tail = null;
616 + if (bodyFinished && !closed) {
617 + closed = true;
618 + close(response);
619 + }
620 + }
621 +
622 busboyStream.on('field', (name, value) => {
572 - if (pendingFiles > 0) {
573 - // Because the 'end' event fires two microtasks after the next 'field'
574 - // we would resolve files and fields out of order. To handle this properly
575 - // we queue any fields we receive until the previous file is done.
576 - queuedFields.push(name, value);
623 + if (tail !== null) {
624 + // A file is in flight; queue the field on the tail (most recent) pending
625 + // file so it resolves after that file, preserving payload order.
626 + if (tail.queuedFields === null) {
627 + tail.queuedFields = [];
628 + }
629 + tail.queuedFields.push(name, value);
630 } else {
631 try {
632 resolveField(response, name, value);
@@ -593,29 +646,46 @@ function decodeReplyFromBusboy<T>(
646 );
647 return;
648 }
596 - pendingFiles++;
649 const file = resolveFileInfo(response, name, filename, mimeType);
650 + const pendingFile: PendingFile = {
651 + name,
652 + file,
653 + complete: false,
654 + queuedFields: null,
655 + next: null,
656 + };
657 + if (tail === null) {
658 + head = pendingFile;
659 + } else {
660 + tail.next = pendingFile;
661 + }
662 + tail = pendingFile;
663 value.on('data', chunk => {
599 - resolveFileChunk(response, file, chunk);
600 - });
601 - value.on('end', () => {
664 try {
603 - resolveFileComplete(response, name, file);
604 - pendingFiles--;
605 - if (pendingFiles === 0) {
606 - // Release any queued fields
607 - for (let i = 0; i < queuedFields.length; i += 2) {
608 - resolveField(response, queuedFields[i], queuedFields[i + 1]);
609 - }
610 - queuedFields.length = 0;
611 - }
665 + resolveFileChunk(response, file, chunk);
666 } catch (error) {
667 busboyStream.destroy(error);
668 }
669 });
670 + value.on('error', error => {
671 + busboyStream.destroy(error);
672 + });
673 + value.on('end', () => {
674 + pendingFile.complete = true;
675 + flush();
676 + });
677 });
678 busboyStream.on('finish', () => {
618 - close(response);
679 + bodyFinished = true;
680 + flush();
681 + if (!closed) {
682 + // Invariant: busboy delays 'finish' until every file's 'end' event has
683 + // fired, so the flush above should always close the response.
684 + reportGlobalError(
685 + response,
686 + new Error('Reply finished with incomplete file part.'),
687 + );
688 + }
689 });
690 busboyStream.on('error', err => {
691 reportGlobalError(
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js new
+149
@@ -0,0 +1,149 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + * @jest-environment node
9 + */
10 +
11 +'use strict';
12 +
13 +let webpackServerMap;
14 +let busboy;
15 +let ReactServerDOMServer;
16 +let ReactServerDOMClient;
17 +
18 +describe('ReactFlightDOMReplyNode', () => {
19 + beforeEach(() => {
20 + jest.resetModules();
21 + // Simulate the condition resolution
22 + jest.mock('react', () => require('react/react.react-server'));
23 + jest.mock('react-server-dom-webpack/server', () =>
24 + require('react-server-dom-webpack/server.node'),
25 + );
26 + const WebpackMock = require('./utils/WebpackMock');
27 + webpackServerMap = WebpackMock.webpackServerMap;
28 + ReactServerDOMServer = require('react-server-dom-webpack/server.node');
29 + jest.resetModules();
30 + ReactServerDOMClient = require('react-server-dom-webpack/client.node');
31 +
32 + busboy = require('busboy');
33 + });
34 +
35 + // Writes the body to busboy as a multipart stream. Blob entries become
36 + // `filename`-bearing parts so busboy emits them as 'file' events (with
37 + // streamed data) rather than 'field' events.
38 + async function pipeBodyToBusboy(bb, body, boundary) {
39 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
40 + for (const [name, value] of body) {
41 + if (typeof value === 'string') {
42 + bb.write(
43 + `--${boundary}\r\n` +
44 + `Content-Disposition: form-data; name="${name}"\r\n` +
45 + `\r\n` +
46 + `${value}\r\n`,
47 + );
48 + } else {
49 + const filename =
50 + typeof value.name === 'string' && value.name !== ''
51 + ? value.name
52 + : 'blob';
53 + const mimeType =
54 + typeof value.type === 'string' && value.type !== ''
55 + ? value.type
56 + : 'application/octet-stream';
57 + const buffer = Buffer.from(await value.arrayBuffer());
58 + bb.write(
59 + `--${boundary}\r\n` +
60 + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
61 + `Content-Type: ${mimeType}\r\n` +
62 + `\r\n`,
63 + );
64 + bb.write(buffer);
65 + bb.write('\r\n');
66 + }
67 + }
68 + bb.end(`--${boundary}--\r\n`);
69 + }
70 +
71 + // FormData iterates entries in insertion order per spec, so a referenced
72 + // FormData must round-trip with its entry order intact even when files
73 + // and text fields are interleaved in the payload.
74 + it('preserves entry order when referenced FormDatas interleave files and text', async () => {
75 + const a = new FormData();
76 + a.append('text_a', 'value_a');
77 + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt');
78 + const b = new FormData();
79 + b.append('text_b', 'value_b');
80 + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt');
81 +
82 + const body = await ReactServerDOMClient.encodeReply([a, b]);
83 + const boundary = 'boundary';
84 + const bb = busboy({
85 + headers: {
86 + 'content-type': `multipart/form-data; boundary=${boundary}`,
87 + },
88 + });
89 + const reply = ReactServerDOMServer.decodeReplyFromBusboy(
90 + bb,
91 + webpackServerMap,
92 + );
93 + await pipeBodyToBusboy(bb, body, boundary);
94 +
95 + const result = await reply;
96 + expect(result).toHaveLength(2);
97 + const [decodedA, decodedB] = result;
98 +
99 + const aEntries = Array.from(decodedA.entries());
100 + expect(aEntries.map(([k]) => k)).toEqual(['text_a', 'file_a']);
101 + expect(aEntries[0][1]).toBe('value_a');
102 + expect(aEntries[1][1]).toBeInstanceOf(File);
103 + expect(aEntries[1][1].name).toBe('a.txt');
104 +
105 + const bEntries = Array.from(decodedB.entries());
106 + expect(bEntries.map(([k]) => k)).toEqual(['text_b', 'file_b']);
107 + expect(bEntries[0][1]).toBe('value_b');
108 + expect(bEntries[1][1]).toBeInstanceOf(File);
109 + expect(bEntries[1][1].name).toBe('b.txt');
110 + });
111 +
112 + // Every entry of a referenced FormData must be present in the decoded
113 + // FormData regardless of where files appear in its iteration order.
114 + it('does not drop entries when referenced FormDatas iterate files before text', async () => {
115 + const a = new FormData();
116 + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt');
117 + a.append('text_a', 'value_a');
118 + const b = new FormData();
119 + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt');
120 + b.append('text_b', 'value_b');
121 +
122 + const body = await ReactServerDOMClient.encodeReply([a, b]);
123 + const boundary = 'boundary';
124 + const bb = busboy({
125 + headers: {
126 + 'content-type': `multipart/form-data; boundary=${boundary}`,
127 + },
128 + });
129 + const reply = ReactServerDOMServer.decodeReplyFromBusboy(
130 + bb,
131 + webpackServerMap,
132 + );
133 + await pipeBodyToBusboy(bb, body, boundary);
134 +
135 + const result = await reply;
136 + expect(result).toHaveLength(2);
137 + const [decodedA, decodedB] = result;
138 +
139 + const aKeys = Array.from(decodedA.keys()).sort();
140 + expect(aKeys).toEqual(['file_a', 'text_a']);
141 + expect(decodedA.get('text_a')).toBe('value_a');
142 + expect(decodedA.get('file_a')).toBeInstanceOf(File);
143 +
144 + const bKeys = Array.from(decodedB.keys()).sort();
145 + expect(bKeys).toEqual(['file_b', 'text_b']);
146 + expect(decodedB.get('text_b')).toBe('value_b');
147 + expect(decodedB.get('file_b')).toBeInstanceOf(File);
148 + });
149 +});
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js
+91 -21
@@ -68,6 +68,7 @@ import {
68 import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
69
70 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
71 +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
72
73 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
74
@@ -551,6 +552,17 @@ function prerender(
552 });
553 }
554
555 +type PendingFile = {
556 + name: string,
557 + file: FileHandle,
558 + complete: boolean,
559 + // Lazily allocated when a text field arrives after this file's 'file'
560 + // event but before its (deferred) 'end' event. Stored as flat
561 + // [name1, value1, name2, value2, ...] pairs.
562 + queuedFields: null | Array<string>,
563 + next: null | PendingFile,
564 +};
565 +
566 function decodeReplyFromBusboy<T>(
567 busboyStream: Busboy,
568 webpackMap: ServerManifest,
@@ -566,14 +578,55 @@ function decodeReplyFromBusboy<T>(
578 undefined,
579 options ? options.arraySizeLimit : undefined,
580 );
569 - let pendingFiles = 0;
570 - const queuedFields: Array<string> = [];
581 +
582 + // Linked list of pending files in arrival (payload) order. Text fields that
583 + // arrive while a file is in flight are queued on the tail file's
584 + // `queuedFields` so they can be resolved together when that file completes.
585 + // Fields that arrive while the list is empty bypass it and resolve
586 + // immediately. This makes the backing FormData's insertion order match the
587 + // payload's entry order.
588 + let head: null | PendingFile = null;
589 + let tail: null | PendingFile = null;
590 + let bodyFinished = false;
591 + let closed = false;
592 +
593 + function flush() {
594 + while (head !== null) {
595 + const current = head;
596 + if (!current.complete) {
597 + // This file is still streaming. Hold later files and fields until it
598 + // completes so the backing FormData reflects payload order.
599 + return;
600 + }
601 + try {
602 + resolveFileComplete(response, current.name, current.file);
603 + const queuedFields = current.queuedFields;
604 + if (queuedFields !== null) {
605 + for (let i = 0; i < queuedFields.length; i += 2) {
606 + resolveField(response, queuedFields[i], queuedFields[i + 1]);
607 + }
608 + }
609 + } catch (error) {
610 + busboyStream.destroy(error);
611 + return;
612 + }
613 + head = current.next;
614 + }
615 + tail = null;
616 + if (bodyFinished && !closed) {
617 + closed = true;
618 + close(response);
619 + }
620 + }
621 +
622 busboyStream.on('field', (name, value) => {
572 - if (pendingFiles > 0) {
573 - // Because the 'end' event fires two microtasks after the next 'field'
574 - // we would resolve files and fields out of order. To handle this properly
575 - // we queue any fields we receive until the previous file is done.
576 - queuedFields.push(name, value);
623 + if (tail !== null) {
624 + // A file is in flight; queue the field on the tail (most recent) pending
625 + // file so it resolves after that file, preserving payload order.
626 + if (tail.queuedFields === null) {
627 + tail.queuedFields = [];
628 + }
629 + tail.queuedFields.push(name, value);
630 } else {
631 try {
632 resolveField(response, name, value);
@@ -593,29 +646,46 @@ function decodeReplyFromBusboy<T>(
646 );
647 return;
648 }
596 - pendingFiles++;
649 const file = resolveFileInfo(response, name, filename, mimeType);
650 + const pendingFile: PendingFile = {
651 + name,
652 + file,
653 + complete: false,
654 + queuedFields: null,
655 + next: null,
656 + };
657 + if (tail === null) {
658 + head = pendingFile;
659 + } else {
660 + tail.next = pendingFile;
661 + }
662 + tail = pendingFile;
663 value.on('data', chunk => {
599 - resolveFileChunk(response, file, chunk);
600 - });
601 - value.on('end', () => {
664 try {
603 - resolveFileComplete(response, name, file);
604 - pendingFiles--;
605 - if (pendingFiles === 0) {
606 - // Release any queued fields
607 - for (let i = 0; i < queuedFields.length; i += 2) {
608 - resolveField(response, queuedFields[i], queuedFields[i + 1]);
609 - }
610 - queuedFields.length = 0;
611 - }
665 + resolveFileChunk(response, file, chunk);
666 } catch (error) {
667 busboyStream.destroy(error);
668 }
669 });
670 + value.on('error', error => {
671 + busboyStream.destroy(error);
672 + });
673 + value.on('end', () => {
674 + pendingFile.complete = true;
675 + flush();
676 + });
677 });
678 busboyStream.on('finish', () => {
618 - close(response);
679 + bodyFinished = true;
680 + flush();
681 + if (!closed) {
682 + // Invariant: busboy delays 'finish' until every file's 'end' event has
683 + // fired, so the flush above should always close the response.
684 + reportGlobalError(
685 + response,
686 + new Error('Reply finished with incomplete file part.'),
687 + );
688 + }
689 });
690 busboyStream.on('error', err => {
691 reportGlobalError(
yarn.lock
+13
@@ -6092,6 +6092,13 @@ bunyan@1.8.15:
6092 mv "~2"
6093 safe-json-stringify "~1"
6094
6095 +busboy@^1.6.0:
6096 + version "1.6.0"
6097 + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
6098 + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
6099 + dependencies:
6100 + streamsearch "^1.1.0"
6101 +
6102 bytes@3.0.0:
6103 version "3.0.0"
6104 resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@@ -8167,6 +8174,7 @@ eslint-plugin-no-unsanitized@4.0.2:
8174
8175 "eslint-plugin-react-internal@link:./scripts/eslint-rules":
8176 version "0.0.0"
8177 + uid ""
8178
8179 eslint-plugin-react@^6.7.1:
8180 version "6.10.3"
@@ -16092,6 +16100,11 @@ stream-shift@^1.0.0:
16100 resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
16101 integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
16102
16103 +streamsearch@^1.1.0:
16104 + version "1.1.0"
16105 + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
16106 + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
16107 +
16108 strict-uri-encode@^1.0.0:
16109 version "1.1.0"
16110 resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"