Skip to content

Commit b362b01

Browse files
BleedingDeveps1lonunstubbable
authored
[Flight Reply] Align Rspack decoders with upstream changes (#1)
* [Flight] Restore standard React version placeholder * [Flight Reply] Early bailout if backing entry for Blob deserialization is not a Blob (react#36055) Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de> (cherry picked from commit 12ba7d8) * [Flight] Avoid consuming cyclic models multiple times Co-authored-by: "Sebastian \"Sebbie\" Silbermann" <sebastian.silbermann@vercel.com> (cherry picked from commit 672b242) * [FlightReply] Type hardening and performance improvements Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de> (cherry picked from commit 795203e) * [FlightReply] Don't drop FormData entries in `decodeReplyFromBusboy` (react#36468) Fixes a regression from react#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 react#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 (cherry picked from commit b91823e) * [FlightReply] Performance improvements when decoding (react#37087) This fixes security vulnerabilities in Server Functions. (cherry picked from commit 1dd4ecb) * [Flight Reply] Align Rspack decoders with upstream changes Mirror the selected Reply changes into the Rspack-owned browser, edge, and Node adapters after the dependency-closed upstream backports. Forward caller-provided array size limits, preserve multipart field/file order, and settle failed async iterators without recursive error re-entry. Add public behavior coverage across every Rspack decoder, action-selection path, cyclic collection type, iterator settlement, and Busboy ordering direction. This is a tactical source-parity change for the existing proposal branch; it does not make that branch current with React main. --------- Co-authored-by: Sebastian "Sebbie" Silbermann <sebastian.silbermann@vercel.com> Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 189d913 commit b362b01

30 files changed

Lines changed: 1622 additions & 244 deletions

.eslintrc.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,8 @@ module.exports = {
589589
CopyInspectedElementPath: 'readonly',
590590
DOMHighResTimeStamp: 'readonly',
591591
EventListener: 'readonly',
592+
// Flow type
593+
FormDataEntryValue: 'readonly',
592594
Iterable: 'readonly',
593595
AsyncIterable: 'readonly',
594596
$AsyncIterable: 'readonly',

flow-typed/environments/bom.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,11 +682,11 @@ declare class FormData {
682682
get(name: string): ?FormDataEntryValue;
683683
getAll(name: string): Array<FormDataEntryValue>;
684684

685-
set(name: string, value: string): void;
685+
set(name: string, value: FormDataEntryValue): void;
686686
set(name: string, value: Blob, filename?: string): void;
687687
set(name: string, value: File, filename?: string): void;
688688

689-
append(name: string, value: string): void;
689+
append(name: string, value: FormDataEntryValue): void;
690690
append(name: string, value: Blob, filename?: string): void;
691691
append(name: string, value: File, filename?: string): void;
692692

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"art": "0.10.1",
5454
"babel-plugin-syntax-hermes-parser": "^0.32.0",
5555
"babel-plugin-syntax-trailing-function-commas": "^6.5.0",
56+
"busboy": "^1.6.0",
5657
"chalk": "^3.0.0",
5758
"cli-table": "^0.3.1",
5859
"coffee-script": "^1.12.7",

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,9 @@ export function processReply(
590590
// Copy all the form fields with a prefix for this reference.
591591
// These must come first in the form order because we assume that all the
592592
// fields are available before this is referenced.
593-
const prefix = formFieldPrefix + refId + '_';
593+
// We include a special marker so that the Server can detect FormData entries
594+
// that are values in referenced FormData objects.
595+
const prefix = formFieldPrefix + '_' + refId + '_';
594596
// $FlowFixMe[prop-missing]: FormData has forEach.
595597
value.forEach((originalValue: string | File, originalKey: string) => {
596598
// $FlowFixMe[incompatible-call]

packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
} from 'react-client/src/ReactFlightClientStreamConfigNode';
6363

6464
import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
65+
import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
6566

6667
export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
6768

@@ -325,6 +326,17 @@ function prerenderToNodeStream(
325326
});
326327
}
327328

329+
type PendingFile = {
330+
name: string,
331+
file: FileHandle,
332+
complete: boolean,
333+
// Lazily allocated when a text field arrives after this file's 'file'
334+
// event but before its (deferred) 'end' event. Stored as flat
335+
// [name1, value1, name2, value2, ...] pairs.
336+
queuedFields: null | Array<string>,
337+
next: null | PendingFile,
338+
};
339+
328340
function decodeReplyFromBusboy<T>(
329341
busboyStream: Busboy,
330342
moduleBasePath: ServerManifest,
@@ -340,14 +352,55 @@ function decodeReplyFromBusboy<T>(
340352
undefined,
341353
options ? options.arraySizeLimit : undefined,
342354
);
343-
let pendingFiles = 0;
344-
const queuedFields: Array<string> = [];
355+
356+
// Linked list of pending files in arrival (payload) order. Text fields that
357+
// arrive while a file is in flight are queued on the tail file's
358+
// `queuedFields` so they can be resolved together when that file completes.
359+
// Fields that arrive while the list is empty bypass it and resolve
360+
// immediately. This makes the backing FormData's insertion order match the
361+
// payload's entry order.
362+
let head: null | PendingFile = null;
363+
let tail: null | PendingFile = null;
364+
let bodyFinished = false;
365+
let closed = false;
366+
367+
function flush() {
368+
while (head !== null) {
369+
const current = head;
370+
if (!current.complete) {
371+
// This file is still streaming. Hold later files and fields until it
372+
// completes so the backing FormData reflects payload order.
373+
return;
374+
}
375+
try {
376+
resolveFileComplete(response, current.name, current.file);
377+
const queuedFields = current.queuedFields;
378+
if (queuedFields !== null) {
379+
for (let i = 0; i < queuedFields.length; i += 2) {
380+
resolveField(response, queuedFields[i], queuedFields[i + 1]);
381+
}
382+
}
383+
} catch (error) {
384+
busboyStream.destroy(error);
385+
return;
386+
}
387+
head = current.next;
388+
}
389+
tail = null;
390+
if (bodyFinished && !closed) {
391+
closed = true;
392+
close(response);
393+
}
394+
}
395+
345396
busboyStream.on('field', (name, value) => {
346-
if (pendingFiles > 0) {
347-
// Because the 'end' event fires two microtasks after the next 'field'
348-
// we would resolve files and fields out of order. To handle this properly
349-
// we queue any fields we receive until the previous file is done.
350-
queuedFields.push(name, value);
397+
if (tail !== null) {
398+
// A file is in flight; queue the field on the tail (most recent) pending
399+
// file so it resolves after that file, preserving payload order.
400+
if (tail.queuedFields === null) {
401+
tail.queuedFields = [];
402+
}
403+
tail.queuedFields.push(name, value);
351404
} else {
352405
try {
353406
resolveField(response, name, value);
@@ -367,29 +420,46 @@ function decodeReplyFromBusboy<T>(
367420
);
368421
return;
369422
}
370-
pendingFiles++;
371423
const file = resolveFileInfo(response, name, filename, mimeType);
424+
const pendingFile: PendingFile = {
425+
name,
426+
file,
427+
complete: false,
428+
queuedFields: null,
429+
next: null,
430+
};
431+
if (tail === null) {
432+
head = pendingFile;
433+
} else {
434+
tail.next = pendingFile;
435+
}
436+
tail = pendingFile;
372437
value.on('data', chunk => {
373-
resolveFileChunk(response, file, chunk);
374-
});
375-
value.on('end', () => {
376438
try {
377-
resolveFileComplete(response, name, file);
378-
pendingFiles--;
379-
if (pendingFiles === 0) {
380-
// Release any queued fields
381-
for (let i = 0; i < queuedFields.length; i += 2) {
382-
resolveField(response, queuedFields[i], queuedFields[i + 1]);
383-
}
384-
queuedFields.length = 0;
385-
}
439+
resolveFileChunk(response, file, chunk);
386440
} catch (error) {
387441
busboyStream.destroy(error);
388442
}
389443
});
444+
value.on('error', error => {
445+
busboyStream.destroy(error);
446+
});
447+
value.on('end', () => {
448+
pendingFile.complete = true;
449+
flush();
450+
});
390451
});
391452
busboyStream.on('finish', () => {
392-
close(response);
453+
bodyFinished = true;
454+
flush();
455+
if (!closed) {
456+
// Invariant: busboy delays 'finish' until every file's 'end' event has
457+
// fired, so the flush above should always close the response.
458+
reportGlobalError(
459+
response,
460+
new Error('Reply finished with incomplete file part.'),
461+
);
462+
}
393463
});
394464
busboyStream.on('error', err => {
395465
reportGlobalError(

packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type ServerReferenceId,
2121
} from '../client/ReactFlightClientConfigBundlerParcel';
2222

23+
import noop from 'shared/noop';
2324
import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
2425

2526
import {
@@ -306,8 +307,8 @@ export function decodeReplyFromAsyncIterable<T>(
306307
reportGlobalError(response, reason);
307308
if (typeof (iterator: any).throw === 'function') {
308309
// The iterator protocol doesn't necessarily include this but a generator do.
309-
// $FlowFixMe should be able to pass mixed
310-
iterator.throw(reason).then(error, error);
310+
// $FlowFixMe[prop-missing] should be able to pass mixed
311+
iterator.throw(reason).then(noop, noop);
311312
}
312313
}
313314

packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {Duplex} from 'stream';
2424

2525
import {Readable} from 'stream';
2626

27+
import noop from 'shared/noop';
2728
import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
2829

2930
import {
@@ -75,6 +76,7 @@ import {
7576
import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
7677

7778
import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
79+
import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
7880

7981
export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
8082

@@ -554,6 +556,17 @@ export function registerServerActions(manifest: ServerManifest) {
554556
serverManifest = manifest;
555557
}
556558

559+
type PendingFile = {
560+
name: string,
561+
file: FileHandle,
562+
complete: boolean,
563+
// Lazily allocated when a text field arrives after this file's 'file'
564+
// event but before its (deferred) 'end' event. Stored as flat
565+
// [name1, value1, name2, value2, ...] pairs.
566+
queuedFields: null | Array<string>,
567+
next: null | PendingFile,
568+
};
569+
557570
export function decodeReplyFromBusboy<T>(
558571
busboyStream: Busboy,
559572
options?: {
@@ -568,14 +581,55 @@ export function decodeReplyFromBusboy<T>(
568581
undefined,
569582
options ? options.arraySizeLimit : undefined,
570583
);
571-
let pendingFiles = 0;
572-
const queuedFields: Array<string> = [];
584+
585+
// Linked list of pending files in arrival (payload) order. Text fields that
586+
// arrive while a file is in flight are queued on the tail file's
587+
// `queuedFields` so they can be resolved together when that file completes.
588+
// Fields that arrive while the list is empty bypass it and resolve
589+
// immediately. This makes the backing FormData's insertion order match the
590+
// payload's entry order.
591+
let head: null | PendingFile = null;
592+
let tail: null | PendingFile = null;
593+
let bodyFinished = false;
594+
let closed = false;
595+
596+
function flush() {
597+
while (head !== null) {
598+
const current = head;
599+
if (!current.complete) {
600+
// This file is still streaming. Hold later files and fields until it
601+
// completes so the backing FormData reflects payload order.
602+
return;
603+
}
604+
try {
605+
resolveFileComplete(response, current.name, current.file);
606+
const queuedFields = current.queuedFields;
607+
if (queuedFields !== null) {
608+
for (let i = 0; i < queuedFields.length; i += 2) {
609+
resolveField(response, queuedFields[i], queuedFields[i + 1]);
610+
}
611+
}
612+
} catch (error) {
613+
busboyStream.destroy(error);
614+
return;
615+
}
616+
head = current.next;
617+
}
618+
tail = null;
619+
if (bodyFinished && !closed) {
620+
closed = true;
621+
close(response);
622+
}
623+
}
624+
573625
busboyStream.on('field', (name, value) => {
574-
if (pendingFiles > 0) {
575-
// Because the 'end' event fires two microtasks after the next 'field'
576-
// we would resolve files and fields out of order. To handle this properly
577-
// we queue any fields we receive until the previous file is done.
578-
queuedFields.push(name, value);
626+
if (tail !== null) {
627+
// A file is in flight; queue the field on the tail (most recent) pending
628+
// file so it resolves after that file, preserving payload order.
629+
if (tail.queuedFields === null) {
630+
tail.queuedFields = [];
631+
}
632+
tail.queuedFields.push(name, value);
579633
} else {
580634
try {
581635
resolveField(response, name, value);
@@ -595,29 +649,46 @@ export function decodeReplyFromBusboy<T>(
595649
);
596650
return;
597651
}
598-
pendingFiles++;
599652
const file = resolveFileInfo(response, name, filename, mimeType);
653+
const pendingFile: PendingFile = {
654+
name,
655+
file,
656+
complete: false,
657+
queuedFields: null,
658+
next: null,
659+
};
660+
if (tail === null) {
661+
head = pendingFile;
662+
} else {
663+
tail.next = pendingFile;
664+
}
665+
tail = pendingFile;
600666
value.on('data', chunk => {
601-
resolveFileChunk(response, file, chunk);
602-
});
603-
value.on('end', () => {
604667
try {
605-
resolveFileComplete(response, name, file);
606-
pendingFiles--;
607-
if (pendingFiles === 0) {
608-
// Release any queued fields
609-
for (let i = 0; i < queuedFields.length; i += 2) {
610-
resolveField(response, queuedFields[i], queuedFields[i + 1]);
611-
}
612-
queuedFields.length = 0;
613-
}
668+
resolveFileChunk(response, file, chunk);
614669
} catch (error) {
615670
busboyStream.destroy(error);
616671
}
617672
});
673+
value.on('error', error => {
674+
busboyStream.destroy(error);
675+
});
676+
value.on('end', () => {
677+
pendingFile.complete = true;
678+
flush();
679+
});
618680
});
619681
busboyStream.on('finish', () => {
620-
close(response);
682+
bodyFinished = true;
683+
flush();
684+
if (!closed) {
685+
// Invariant: busboy delays 'finish' until every file's 'end' event has
686+
// fired, so the flush above should always close the response.
687+
reportGlobalError(
688+
response,
689+
new Error('Reply finished with incomplete file part.'),
690+
);
691+
}
621692
});
622693
busboyStream.on('error', err => {
623694
reportGlobalError(
@@ -692,8 +763,8 @@ export function decodeReplyFromAsyncIterable<T>(
692763
reportGlobalError(response, reason);
693764
if (typeof (iterator: any).throw === 'function') {
694765
// The iterator protocol doesn't necessarily include this but a generator do.
695-
// $FlowFixMe should be able to pass mixed
696-
iterator.throw(reason).then(error, error);
766+
// $FlowFixMe[prop-missing] should be able to pass mixed
767+
iterator.throw(reason).then(noop, noop);
697768
}
698769
}
699770

0 commit comments

Comments
 (0)