Skip to content

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

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

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

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

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

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

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

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

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

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

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [FlightReply] Don't drop FormData entries in `decodeReplyFromBusboy` … · react/react@b91823e · GitHub
Skip to content

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

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

Commit b91823e

Browse files
authored
[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. fixesvercel/next.js#93822
1 parent f6790c1 commit b91823e

8 files changed

Lines changed: 618 additions & 105 deletions

File tree

‎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-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
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
65+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
6566

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

@@ -329,6 +330,17 @@ function prerenderToNodeStream(
329330
});
330331
}
331332

333+
typePendingFile={
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+
332344
functiondecodeReplyFromBusboy<T>(
333345
busboyStream: Busboy,
334346
moduleBasePath: ServerManifest,
@@ -344,14 +356,55 @@ function decodeReplyFromBusboy<T>(
344356
undefined,
345357
options ? options.arraySizeLimit : undefined,
346358
);
347-
letpendingFiles=0;
348-
constqueuedFields: 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+
lethead: null|PendingFile=null;
367+
lettail: null|PendingFile=null;
368+
letbodyFinished=false;
369+
letclosed=false;
370+
371+
functionflush(){
372+
while(head!==null){
373+
constcurrent=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+
constqueuedFields=current.queuedFields;
382+
if(queuedFields!==null){
383+
for(leti=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+
349400
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);
355408
}else{
356409
try{
357410
resolveField(response,name,value);
@@ -371,29 +424,46 @@ function decodeReplyFromBusboy<T>(
371424
);
372425
return;
373426
}
374-
pendingFiles++;
375427
constfile=resolveFileInfo(response,name,filename,mimeType);
428+
constpendingFile: 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;
376441
value.on('data',chunk=>{
377-
resolveFileChunk(response,file,chunk);
378-
});
379-
value.on('end',()=>{
380442
try{
381-
resolveFileComplete(response,name,file);
382-
pendingFiles--;
383-
if(pendingFiles===0){
384-
// Release any queued fields
385-
for(leti=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);
390444
}catch(error){
391445
busboyStream.destroy(error);
392446
}
393447
});
448+
value.on('error',error=>{
449+
busboyStream.destroy(error);
450+
});
451+
value.on('end',()=>{
452+
pendingFile.complete=true;
453+
flush();
454+
});
394455
});
395456
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+
newError('Reply finished with incomplete file part.'),
465+
);
466+
}
397467
});
398468
busboyStream.on('error',err=>{
399469
reportGlobalError(

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

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import{textEncoder}from'react-server/src/ReactServerStreamConfigNode';
7676

7777
importtype{TemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
78+
importtype{FileHandle}from'react-server/src/ReactFlightReplyServer';
7879

7980
export{createTemporaryReferenceSet}from'react-server/src/ReactFlightServerTemporaryReferences';
8081

@@ -560,6 +561,17 @@ export function registerServerActions(manifest: ServerManifest) {
560561
serverManifest=manifest;
561562
}
562563

564+
typePendingFile={
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+
563575
exportfunctiondecodeReplyFromBusboy<T>(
564576
busboyStream: Busboy,
565577
options?: {
@@ -574,14 +586,55 @@ export function decodeReplyFromBusboy<T>(
574586
undefined,
575587
options ? options.arraySizeLimit : undefined,
576588
);
577-
letpendingFiles=0;
578-
constqueuedFields: 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+
lethead: null|PendingFile=null;
597+
lettail: null|PendingFile=null;
598+
letbodyFinished=false;
599+
letclosed=false;
600+
601+
functionflush(){
602+
while(head!==null){
603+
constcurrent=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+
constqueuedFields=current.queuedFields;
612+
if(queuedFields!==null){
613+
for(leti=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+
579630
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);
585638
}else{
586639
try{
587640
resolveField(response,name,value);
@@ -601,29 +654,46 @@ export function decodeReplyFromBusboy<T>(
601654
);
602655
return;
603656
}
604-
pendingFiles++;
605657
constfile=resolveFileInfo(response,name,filename,mimeType);
658+
constpendingFile: 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;
606671
value.on('data',chunk=>{
607-
resolveFileChunk(response,file,chunk);
608-
});
609-
value.on('end',()=>{
610672
try{
611-
resolveFileComplete(response,name,file);
612-
pendingFiles--;
613-
if(pendingFiles===0){
614-
// Release any queued fields
615-
for(leti=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);
620674
}catch(error){
621675
busboyStream.destroy(error);
622676
}
623677
});
678+
value.on('error',error=>{
679+
busboyStream.destroy(error);
680+
});
681+
value.on('end',()=>{
682+
pendingFile.complete=true;
683+
flush();
684+
});
624685
});
625686
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+
newError('Reply finished with incomplete file part.'),
695+
);
696+
}
627697
});
628698
busboyStream.on('error',err=>{
629699
reportGlobalError(

0 commit comments

Comments
 (0)