[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
}