@samitouri / QOS-React / commits / 81e442eaf3

[FlightReply] Performance improvements when decoding (#37090)

Security Patches included in 19.2.8 Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>

Josh Story committed Jul 21, 2026 at 09:26 UTC 81e442eaf3fbfabd2367a0d8691ec0df13a3add0
10 files changed +65 -54
packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js
+2 -1
@@ -21,6 +21,7 @@ import {
21 type ServerReferenceId,
22 } from '../client/ReactFlightClientConfigBundlerParcel';
23
24 +import noop from 'shared/noop';
25 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
26
27 import {
@@ -314,7 +315,7 @@ export function decodeReplyFromAsyncIterable<T>(
315 if (typeof (iterator as any).throw === 'function') {
316 // The iterator protocol doesn't necessarily include this but a generator do.
317 // $FlowFixMe[prop-missing] should be able to pass mixed
317 - iterator.throw(reason).then(error, error);
318 + iterator.throw(reason).then(noop, noop);
319 }
320 }
321
packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js
+2 -1
@@ -24,6 +24,7 @@ import type {Duplex} from 'stream';
24
25 import {Readable} from 'stream';
26
27 +import noop from 'shared/noop';
28 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
29
30 import {
@@ -772,7 +773,7 @@ export function decodeReplyFromAsyncIterable<T>(
773 if (typeof (iterator as any).throw === 'function') {
774 // The iterator protocol doesn't necessarily include this but a generator do.
775 // $FlowFixMe[prop-missing] should be able to pass mixed
775 - iterator.throw(reason).then(error, error);
776 + iterator.throw(reason).then(noop, noop);
777 }
778 }
779
packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js
+2 -1
@@ -15,6 +15,7 @@ import type {Thenable} from 'shared/ReactTypes';
15 import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler';
16 import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';
17
18 +import noop from 'shared/noop';
19 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
20
21 import {
@@ -313,7 +314,7 @@ function decodeReplyFromAsyncIterable<T>(
314 if (typeof (iterator as any).throw === 'function') {
315 // The iterator protocol doesn't necessarily include this but a generator do.
316 // $FlowFixMe[prop-missing] should be able to pass mixed
316 - iterator.throw(reason).then(error, error);
317 + iterator.throw(reason).then(noop, noop);
318 }
319 }
320
packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js
+2 -1
@@ -22,6 +22,7 @@ import type {Duplex} from 'stream';
22
23 import {Readable} from 'stream';
24
25 +import noop from 'shared/noop';
26 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
27
28 import {
@@ -766,7 +767,7 @@ function decodeReplyFromAsyncIterable<T>(
767 if (typeof (iterator as any).throw === 'function') {
768 // The iterator protocol doesn't necessarily include this but a generator do.
769 // $FlowFixMe[prop-missing] should be able to pass mixed
769 - iterator.throw(reason).then(error, error);
770 + iterator.throw(reason).then(noop, noop);
771 }
772 }
773
packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js
+2 -1
@@ -22,6 +22,7 @@ import type {Duplex} from 'stream';
22
23 import {Readable} from 'stream';
24
25 +import noop from 'shared/noop';
26 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
27
28 import {
@@ -766,7 +767,7 @@ function decodeReplyFromAsyncIterable<T>(
767 if (typeof (iterator as any).throw === 'function') {
768 // The iterator protocol doesn't necessarily include this but a generator do.
769 // $FlowFixMe[prop-missing] should be able to pass mixed
769 - iterator.throw(reason).then(error, error);
770 + iterator.throw(reason).then(noop, noop);
771 }
772 }
773
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js
+2 -1
@@ -15,6 +15,7 @@ import type {Thenable} from 'shared/ReactTypes';
15 import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler';
16 import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';
17
18 +import noop from 'shared/noop';
19 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
20
21 import {
@@ -313,7 +314,7 @@ function decodeReplyFromAsyncIterable<T>(
314 if (typeof (iterator as any).throw === 'function') {
315 // The iterator protocol doesn't necessarily include this but a generator do.
316 // $FlowFixMe[prop-missing] should be able to pass mixed
316 - iterator.throw(reason).then(error, error);
317 + iterator.throw(reason).then(noop, noop);
318 }
319 }
320
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js
+2 -1
@@ -22,6 +22,7 @@ import type {Duplex} from 'stream';
22
23 import {Readable} from 'stream';
24
25 +import noop from 'shared/noop';
26 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
27
28 import {
@@ -767,7 +768,7 @@ function decodeReplyFromAsyncIterable<T>(
768 if (typeof (iterator as any).throw === 'function') {
769 // The iterator protocol doesn't necessarily include this but a generator do.
770 // $FlowFixMe[prop-missing] should be able to pass mixed
770 - iterator.throw(reason).then(error, error);
771 + iterator.throw(reason).then(noop, noop);
772 }
773 }
774
packages/react-server/src/ReactFlightActionServer.js
+45 -44
@@ -112,53 +112,50 @@ export function decodeAction<T>(
112 // the implementation details of the action data.
113 const formData = new FormData();
114
115 - let action: Promise<(formData: FormData) => T> | null = null;
116 - const seenActions = new Set<string>();
115 + let maybeActionKey: null | string = null;
116
117 // $FlowFixMe[prop-missing]
118 body.forEach((value: string | File, key: string) => {
119 if (!key.startsWith('$ACTION_')) {
120 // $FlowFixMe[incompatible-type]
121 formData.append(key, value);
123 - return;
124 - }
125 - // Later actions may override earlier actions if a button is used to
126 - // override the default form action. However, we don't expect the same
127 - // action ref field to be sent multiple times in legitimate form data.
128 - if (key.startsWith('$ACTION_REF_')) {
129 - if (seenActions.has(key)) {
130 - return;
131 - }
132 - seenActions.add(key);
133 - const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
134 - const metaData = decodeBoundActionMetaData(
135 - body,
136 - serverManifest,
137 - formFieldPrefix,
138 - );
139 - action = loadServerReference(serverManifest, metaData);
140 - return;
141 - }
142 - // A simple action with no bound arguments may appear twice in the form data
143 - // if a button specifies the same action as the default form action. We only
144 - // load the first one, as they're guaranteed to be identical.
145 - if (key.startsWith('$ACTION_ID_')) {
146 - if (seenActions.has(key)) {
147 - return;
148 - }
149 - seenActions.add(key);
150 - const id = key.slice(11);
151 - action = loadServerReference(serverManifest, {
152 - id,
153 - bound: null,
154 - });
155 - return;
122 + } else if (key.startsWith('$ACTION_REF_')) {
123 + // Later actions may override earlier actions if a button is used to
124 + // override the default form action. However, we don't expect the same
125 + // action ref field to be sent multiple times in legitimate form data.
126 + maybeActionKey = key;
127 + } else if (key.startsWith('$ACTION_ID_')) {
128 + // A simple action with no bound arguments may appear twice in the form data
129 + // if a button specifies the same action as the default form action.
130 + maybeActionKey = key;
131 }
132 });
133
159 - if (action === null) {
134 + if (maybeActionKey === null) {
135 return null;
136 }
137 + const actionKey = maybeActionKey;
138 +
139 + let action: Promise<(formData: FormData) => T> | null = null;
140 + if (actionKey.startsWith('$ACTION_REF_')) {
141 + const formFieldPrefix =
142 + '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':';
143 + const metaData = decodeBoundActionMetaData(
144 + body,
145 + serverManifest,
146 + formFieldPrefix,
147 + );
148 + action = loadServerReference(serverManifest, metaData);
149 + } else if (actionKey.startsWith('$ACTION_ID_')) {
150 + const id = actionKey.slice('$ACTION_ID_'.length);
151 + action = loadServerReference(serverManifest, {
152 + id,
153 + bound: null,
154 + });
155 + } else {
156 + throw new Error('Cannot handle action key. This is a bug in React.');
157 + }
158 +
159 // Return the action with the remaining FormData bound to the first argument.
160 return action.then(fn => fn.bind(null, formData));
161 }
@@ -175,24 +172,28 @@ export function decodeFormState<S>(
172 }
173 // Search through the form data object to get the reference id and the number
174 // of bound arguments. This repeats some of the work done in decodeAction.
178 - let metaData = null;
175 + let actionKey: null | string = null;
176 // $FlowFixMe[prop-missing]
177 body.forEach((value: string | File, key: string) => {
178 if (key.startsWith('$ACTION_REF_')) {
182 - const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
183 - metaData = decodeBoundActionMetaData(
184 - body,
185 - serverManifest,
186 - formFieldPrefix,
187 - );
179 + actionKey = key;
180 }
181 // We don't check for the simple $ACTION_ID_ case because form state actions
182 // are always bound to the state argument.
183 });
192 - if (metaData === null) {
184 + if (actionKey === null) {
185 // Should be unreachable.
186 return Promise.resolve(null);
187 }
188 +
189 + const formFieldPrefix =
190 + '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':';
191 + const metaData = decodeBoundActionMetaData(
192 + body,
193 + serverManifest,
194 + formFieldPrefix,
195 + );
196 +
197 const referenceId = metaData.id;
198 return Promise.resolve(metaData.bound).then(bound => {
199 if (bound === null) {
packages/react-server/src/ReactFlightServer.js
+4 -2
@@ -1404,7 +1404,7 @@ function serializeAsyncIterable(
1404 if (typeof (iterator as any).throw === 'function') {
1405 // The iterator protocol doesn't necessarily include this but a generator do.
1406 // $FlowFixMe[prop-missing] should be able to pass mixed
1407 - iterator.throw(reason).then(error, error);
1407 + iterator.throw(reason).then(noop, noop);
1408 }
1409 }
1410 function abortIterable() {
@@ -1424,9 +1424,11 @@ function serializeAsyncIterable(
1424 enqueueFlush(request);
1425 }
1426 if (typeof (iterator as any).throw === 'function') {
1427 + // TODO: Premature exits should call return() on the iterator if it exists
1428 + // to allow cleanup. See https://tc39.es/ecma262/multipage/control-abstraction-objects.html#table-async-iterator-optional
1429 // The iterator protocol doesn't necessarily include this but a generator do.
1430 // $FlowFixMe[prop-missing] should be able to pass mixed
1429 - iterator.throw(reason).then(error, error);
1431 + iterator.throw(reason).then(noop, noop);
1432 }
1433 }
1434 request.cacheController.signal.addEventListener('abort', abortIterable);
scripts/error-codes/codes.json
+2 -1
@@ -586,5 +586,6 @@
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 "600": "A rejected Promise was passed to React without a `reason` property. React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.",
589 - "601": "A chunk pair is incomplete. This is a bug in React."
589 + "601": "A chunk pair is incomplete. This is a bug in React.",
590 + "602": "Cannot handle action key. This is a bug in React."
591 }