@samitouri / QOS-React-2 / commits / dd453071d9

[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