[FlightReply] Type hardening and performance improvements (#36425)
Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
Sebastian "Sebbie" Silbermann committed
May 6, 2026 at 19:39 UTC
dd453071d976a2afc0c7fcd10f1821c621be61df
6 files changed
+214
-42
.eslintrc.js
+2
@@ -582,6 +582,8 @@ module.exports = {
582
CopyInspectedElementPath: 'readonly',
583
DOMHighResTimeStamp: 'readonly',
584
EventListener: 'readonly',
585
+ // Flow type
586
+ FormDataEntryValue: 'readonly',
587
Iterable: 'readonly',
588
AsyncIterable: 'readonly',
589
$AsyncIterable: 'readonly',
flow-typed/environments/bom.js
+2
-2
@@ -682,11 +682,11 @@ declare class FormData {
682
get(name: string): ?FormDataEntryValue;
683
getAll(name: string): Array<FormDataEntryValue>;
684
685
- set(name: string, value: string): void;
685
+ set(name: string, value: FormDataEntryValue): void;
686
set(name: string, value: Blob, filename?: string): void;
687
set(name: string, value: File, filename?: string): void;
688
689
- append(name: string, value: string): void;
689
+ append(name: string, value: FormDataEntryValue): void;
690
append(name: string, value: Blob, filename?: string): void;
691
append(name: string, value: File, filename?: string): void;
692
packages/react-client/src/ReactFlightReplyClient.js
+3
-1
@@ -598,7 +598,9 @@ export function processReply(
598
// Copy all the form fields with a prefix for this reference.
599
// These must come first in the form order because we assume that all the
600
// fields are available before this is referenced.
601
- const prefix = formFieldPrefix + refId + '_';
601
+ // We include a special marker so that the Server can detect FormData entries
602
+ // that are values in referenced FormData objects.
603
+ const prefix = formFieldPrefix + '_' + refId + '_';
604
// $FlowFixMe[prop-missing]: FormData has forEach.
605
value.forEach((originalValue: string | File, originalKey: string) => {
606
// $FlowFixMe[incompatible-call]
packages/react-server/src/ReactFlightReplyBackingFormData.js
new
+107
@@ -0,0 +1,107 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+/**
11
+ * Backing FormData is a wrapper around FormData that allows iterating over the
12
+ * keys while allowing to evict values from the FormData without affecting the iteration.
13
+ * Native FormData.keys() will skip keys if entries with Blob are deleted e.g.
14
+ * ```js
15
+ * const formData = new FormData();
16
+ * formData.append('a', new Blob());
17
+ * formData.append('b', 2);
18
+ * const keys = formData.keys();
19
+ * keys.next().value; // 'a'
20
+ * formData.delete('a');
21
+ * keys.next().value; // undefined, but we expect 'b'
22
+ * ```
23
+ */
24
+export opaque type BackingFormData = {
25
+ data: FormData,
26
+ keyPointer: number,
27
+ // Lazily initialized array of keys. We only need this at the moment
28
+ // for referenced FormData.
29
+ keys: null | Array<string>,
30
+};
31
+
32
+export function peekBackingEntry(backingStore: BackingFormData): string | void {
33
+ let keys = backingStore.keys;
34
+ if (keys === null) {
35
+ keys = backingStore.keys = Array.from(backingStore.data.keys());
36
+ backingStore.keyPointer = 0;
37
+ }
38
+
39
+ return keys[backingStore.keyPointer];
40
+}
41
+
42
+export function advanceBackingEntryIterator(
43
+ backingStore: BackingFormData,
44
+): void {
45
+ backingStore.keyPointer++;
46
+}
47
+
48
+export function consumeBackingEntry(
49
+ backingStore: BackingFormData,
50
+ key: string,
51
+): void {
52
+ backingStore.data.delete(key);
53
+ backingStore.keyPointer++;
54
+}
55
+
56
+export function appendBackingEntry(
57
+ backingStore: BackingFormData,
58
+ key: string,
59
+ value: FormDataEntryValue,
60
+): void {
61
+ backingStore.data.append(key, value);
62
+ let keys = backingStore.keys;
63
+ if (keys === null) {
64
+ keys = backingStore.keys = Array.from(backingStore.data.keys());
65
+ backingStore.keyPointer = 0;
66
+ } else {
67
+ keys.push(key);
68
+ }
69
+}
70
+
71
+export function appendBackingFile(
72
+ backingStore: BackingFormData,
73
+ key: string,
74
+ value: Blob,
75
+ filename: string,
76
+): void {
77
+ backingStore.data.append(key, value, filename);
78
+ let keys = backingStore.keys;
79
+ if (keys === null) {
80
+ keys = backingStore.keys = Array.from(backingStore.data.keys());
81
+ backingStore.keyPointer = 0;
82
+ } else {
83
+ keys.push(key);
84
+ }
85
+}
86
+
87
+export function getBackingEntry(
88
+ backingStore: BackingFormData,
89
+ key: string,
90
+): ?FormDataEntryValue {
91
+ return backingStore.data.get(key);
92
+}
93
+
94
+export function getAllBackingEntries(
95
+ backingStore: BackingFormData,
96
+ key: string,
97
+): Array<FormDataEntryValue> {
98
+ return backingStore.data.getAll(key);
99
+}
100
+
101
+export function createBackingFormData(formData: FormData): BackingFormData {
102
+ return {
103
+ data: formData,
104
+ keyPointer: -1,
105
+ keys: null,
106
+ };
107
+}
packages/react-server/src/ReactFlightReplyServer.js
+98
-38
@@ -18,6 +18,7 @@ import type {
18
ClientReference as ServerReference,
19
} from 'react-client/src/ReactFlightClientConfig';
20
21
+import type {BackingFormData} from './ReactFlightReplyBackingFormData';
22
import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences';
23
24
import {
@@ -26,6 +27,16 @@ import {
27
requireModule,
28
} from 'react-client/src/ReactFlightClientConfig';
29
30
+import {
31
+ createBackingFormData,
32
+ advanceBackingEntryIterator,
33
+ appendBackingEntry,
34
+ appendBackingFile,
35
+ consumeBackingEntry,
36
+ getBackingEntry,
37
+ getAllBackingEntries,
38
+ peekBackingEntry,
39
+} from './ReactFlightReplyBackingFormData';
40
import {
41
createTemporaryReference,
42
registerTemporaryReference,
@@ -192,7 +203,7 @@ const ArrayPrototype = Array.prototype;
203
export type Response = {
204
_bundlerConfig: ServerManifest,
205
_prefix: string,
195
- _formData: FormData,
206
+ _formData: BackingFormData,
207
_chunks: Map<number, SomeChunk<any>>,
208
_closed: boolean,
209
_closedReason: mixed,
@@ -606,7 +617,13 @@ function reviveModel(
617
if (value.length > 1) {
618
childContext.fork = true;
619
}
609
- bumpArrayCount(childContext, value.length + 1, response);
620
+ bumpArrayCount(
621
+ childContext,
622
+ // Number of commas + square brackets
623
+ // value.length - 1 + 2
624
+ value.length + 1,
625
+ response,
626
+ );
627
for (let i = 0; i < value.length; i++) {
628
const childRef =
629
reference !== undefined ? reference + ':' + i : undefined;
@@ -691,7 +708,9 @@ type InitializationReference = {
708
type InitializationHandler = {
709
chunk: null | BlockedChunk<any>,
710
value: any,
694
- reason: any,
711
+ // TODO: Split type to make it impossible to treat a thrown value as NestedArrayContext.
712
+ // thrown value if errored, otherwise array context
713
+ reason: mixed | NestedArrayContext,
714
deps: number,
715
errored: boolean,
716
};
@@ -786,11 +805,16 @@ export function reportGlobalError(response: Response, error: Error): void {
805
// because we won't be getting any new data to resolve it.
806
if (chunk.status === PENDING) {
807
triggerErrorOnChunk(response, chunk, error);
789
- } else if (chunk.status === INITIALIZED && chunk.reason !== null) {
790
- const maybeController = chunk.reason;
791
- // $FlowFixMe
792
- if (typeof maybeController.error === 'function') {
793
- maybeController.error(error);
808
+ } else if (chunk.status === INITIALIZED) {
809
+ const initializedChunk:
810
+ | InitializedChunk<any>
811
+ | InitializedStreamChunk<any> = (chunk: any);
812
+ if (initializedChunk.reason !== null) {
813
+ const maybeController = initializedChunk.reason;
814
+ // $FlowFixMe[method-unbinding] Just doing a typeof check
815
+ if (typeof maybeController.error === 'function') {
816
+ maybeController.error(error);
817
+ }
818
}
819
}
820
});
@@ -803,7 +827,7 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
827
const prefix = response._prefix;
828
const key = prefix + id;
829
// Check if we have this field in the backing store already.
806
- const backingEntry = response._formData.get(key);
830
+ const backingEntry = getBackingEntry(response._formData, key);
831
if (typeof backingEntry === 'string') {
832
chunk = createResolvedModelChunk(response, backingEntry, id);
833
} else if (response._closed) {
@@ -924,7 +948,9 @@ function resolveReference(
948
const initializedChunk: InitializedChunk<any> = (chunk: any);
949
initializedChunk.status = INITIALIZED;
950
initializedChunk.value = handler.value;
927
- initializedChunk.reason = handler.reason; // Used by streaming chunks
951
+ initializedChunk.reason =
952
+ // $FlowFixMe[incompatible-type] Assuming handler.errored is false.
953
+ handler.reason;
954
if (resolveListeners !== null) {
955
wakeChunk(response, resolveListeners, handler.value, initializedChunk);
956
}
@@ -1011,17 +1037,30 @@ function getOutlinedModel<T>(
1037
): T {
1038
const path = reference.split(':');
1039
const id = parseInt(path[0], 16);
1014
- const chunk = getChunk(response, id);
1040
+ let chunk = getChunk(response, id);
1041
switch (chunk.status) {
1042
case RESOLVED_MODEL:
1043
initializeModelChunk(chunk);
1044
+ // $FlowFixMe[incompatible-cast] We just initialized this chunk so it can't be a ResolvedModelChunk anymore.
1045
+ chunk = (chunk: Exclude<SomeChunk<T>, ResolvedModelChunk<T>>);
1046
break;
1047
}
1048
// The status might have changed after initialization.
1049
switch (chunk.status) {
1050
case INITIALIZED:
1051
let value = chunk.value;
1024
- let arrayRoot: null | NestedArrayContext = chunk.reason;
1052
+ const arrayRootOrController:
1053
+ | null
1054
+ | NestedArrayContext
1055
+ | FlightStreamController = chunk.reason;
1056
+ if (arrayRootOrController !== null && 'error' in arrayRootOrController) {
1057
+ throw new Error(
1058
+ 'Expected an initialized chunk but got an initialized stream chunk instead. ' +
1059
+ 'This payload may have been submitted by an older version of React.',
1060
+ );
1061
+ }
1062
+ let arrayRoot = arrayRootOrController;
1063
+
1064
let localLength: number = 0;
1065
const rootArrayContexts = response._rootArrayContexts;
1066
for (let i = 1; i < path.length; i++) {
@@ -1036,7 +1075,11 @@ function getOutlinedModel<T>(
1075
value = value[name];
1076
if (isArray(value)) {
1077
localLength = 0;
1039
- arrayRoot = rootArrayContexts.get(value) || arrayRoot;
1078
+ arrayRoot =
1079
+ rootArrayContexts.get(
1080
+ // $FlowFixMe[incompatible-cast] Our `isArray` typing can't narrow `mixed`
1081
+ (value: $ReadOnlyArray<mixed>),
1082
+ ) || arrayRoot;
1083
} else {
1084
arrayRoot = null;
1085
if (typeof value === 'string') {
@@ -1195,7 +1238,7 @@ function parseTypedArray<T: $ArrayBufferView | ArrayBuffer>(
1238
1239
// We should have this backingEntry in the store already because we emitted
1240
// it before referencing it. It should be a Blob.
1198
- const backingEntry: Blob = (response._formData.get(key): any);
1241
+ const backingEntry: Blob = (getBackingEntry(response._formData, key): any);
1242
1243
const promise: Promise<ArrayBuffer> = backingEntry.arrayBuffer();
1244
@@ -1295,7 +1338,7 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
1338
1339
const prefix = response._prefix;
1340
const key = prefix + id;
1298
- const existingEntries = response._formData.getAll(key);
1341
+ const existingEntries = getAllBackingEntries(response._formData, key);
1342
for (let i = 0; i < existingEntries.length; i++) {
1343
const value = existingEntries[i];
1344
if (typeof value === 'string') {
@@ -1599,28 +1642,41 @@ function parseModelString(
1642
case 'K': {
1643
// FormData
1644
const stringId = value.slice(2);
1602
- const formPrefix = response._prefix + stringId + '_';
1645
+
1646
+ const responsePrefix = response._prefix;
1647
+ // Use the special marker from the Client to distinguish keys that should
1648
+ // be consumed by referenced FormData.
1649
+ const anyFormPrefix = responsePrefix + '_';
1650
+ const formPrefix = anyFormPrefix + stringId + '_';
1651
+
1652
const data = new FormData();
1653
const backingFormData = response._formData;
1605
- // We assume that the reference to FormData always comes after each
1606
- // entry that it references so we can assume they all exist in the
1607
- // backing store already.
1608
- // Clone the keys to workaround bugs in the delete-while-iterating
1609
- // algorithm of FormData.
1610
- const keys = Array.from(backingFormData.keys());
1611
- for (let i = 0; i < keys.length; i++) {
1612
- const entryKey = keys[i];
1613
- if (entryKey.startsWith(formPrefix)) {
1614
- const entries = backingFormData.getAll(entryKey);
1615
- const newKey = entryKey.slice(formPrefix.length);
1616
- for (let j = 0; j < entries.length; j++) {
1654
+ // We're still transpiling for-of loops, so we have to use the iterator directly instead of a for-of loop.
1655
+ while (true) {
1656
+ const formDataKey = peekBackingEntry(backingFormData);
1657
+ if (formDataKey === undefined) {
1658
+ break;
1659
+ }
1660
+ if (formDataKey.startsWith(formPrefix)) {
1661
+ const referencedFormDataValue = getAllBackingEntries(
1662
+ backingFormData,
1663
+ formDataKey,
1664
+ );
1665
+ const referencedFormDataKey = formDataKey.slice(formPrefix.length);
1666
+ for (let i = 0; i < referencedFormDataValue.length; i++) {
1667
// $FlowFixMe[incompatible-call]
1618
- data.append(newKey, entries[j]);
1668
+ data.append(referencedFormDataKey, referencedFormDataValue[i]);
1669
}
1620
- // These entries have now all been consumed. Let's free it.
1621
- // This also ensures that we don't have any entries left if we
1622
- // see the same key twice.
1623
- backingFormData.delete(entryKey);
1670
+ consumeBackingEntry(backingFormData, formDataKey);
1671
+ } else if (formDataKey.startsWith(anyFormPrefix)) {
1672
+ // The FormData values are continuous and before the FormData reference.
1673
+ // If we see something that doesn't look like a value for a referenced
1674
+ // FormData, we can assume we're past the values for this FormData
1675
+ // reference and stop iterating.
1676
+ break;
1677
+ } else {
1678
+ // Either an outlined value or something not owned by this Reply.
1679
+ advanceBackingEntryIterator(backingFormData);
1680
}
1681
}
1682
return data;
@@ -1809,7 +1865,10 @@ function parseModelString(
1865
const blobKey = prefix + id;
1866
// We should have this backingEntry in the store already because we emitted
1867
// it before referencing it. It should be a Blob.
1812
- const backingEntry = response._formData.get(blobKey);
1868
+ const backingEntry: Blob = (getBackingEntry(
1869
+ response._formData,
1870
+ blobKey,
1871
+ ): any);
1872
if (!(backingEntry instanceof Blob)) {
1873
throw new Error('Referenced Blob is not a Blob.');
1874
}
@@ -1856,10 +1915,11 @@ export function createResponse(
1915
arraySizeLimit?: number = DEFAULT_MAX_ARRAY_NESTING,
1916
): Response {
1917
const chunks: Map<number, SomeChunk<any>> = new Map();
1918
+
1919
const response: Response = {
1920
_bundlerConfig: bundlerConfig,
1921
_prefix: formFieldPrefix,
1862
- _formData: backingFormData,
1922
+ _formData: createBackingFormData(backingFormData),
1923
_chunks: chunks,
1924
_closed: false,
1925
_closedReason: null,
@@ -1876,7 +1936,7 @@ export function resolveField(
1936
value: string,
1937
): void {
1938
// Add this field to the backing store.
1879
- response._formData.append(key, value);
1939
+ appendBackingEntry(response._formData, key, value);
1940
const prefix = response._prefix;
1941
if (key.startsWith(prefix)) {
1942
const chunks = response._chunks;
@@ -1891,7 +1951,7 @@ export function resolveField(
1951
1952
export function resolveFile(response: Response, key: string, file: File): void {
1953
// Add this field to the backing store.
1894
- response._formData.append(key, file);
1954
+ appendBackingEntry(response._formData, key, file);
1955
}
1956
1957
export opaque type FileHandle = {
@@ -1931,7 +1991,7 @@ export function resolveFileComplete(
1991
// the append() form that takes the file name as the third argument,
1992
// to create a File object.
1993
const blob = new Blob(handle.chunks, {type: handle.mime});
1934
- response._formData.append(key, blob, handle.filename);
1994
+ appendBackingFile(response._formData, key, blob, handle.filename);
1995
}
1996
1997
export function close(response: Response): void {
scripts/error-codes/codes.json
+2
-1
@@ -583,5 +583,6 @@
583
"595": "Attempted to call %s() from the server but %s is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.",
584
"596": "Could not find the module \"%s\" in the React Client Manifest. This is probably a bug in the React Server Components bundler.",
585
"597": "The module \"%s\" is marked as an async ESM module but was loaded as a CJS proxy. This is probably a bug in the React Server Components bundler.",
586
- "598": "Maximum update depth exceeded. This could be an infinite loop. This can happen when a component repeatedly calls setState during render phase or inside useLayoutEffect, causing infinite render loop. React limits the number of nested updates to prevent infinite loops."
586
+ "598": "Maximum update depth exceeded. This could be an infinite loop. This can happen when a component repeatedly calls setState during render phase or inside useLayoutEffect, causing infinite render loop. React limits the number of nested updates to prevent infinite loops.",
587
+ "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React."
588
}
\ No newline at end of file