[Flight] Serialize already resolved Promises as debug models (#33588)
We already support serializing the values of instrumented Promises as debug values such as in console logs. However, we don't support plain native promises. This waits a microtask to see if we can read the value within a microtask and if so emit it. This is so that we can still close the connection. Otherwise, we emit a "halted" row into its row id which replaces the old "Infinite Promise" reference. We could potentially wait until the end of the render before cancelling so that if it resolves before we exit we can still include its value but that would require a bit more work. Ideally we'd have a way to get these lazily later anyway.
Sebastian Markbåge committed
Jun 22, 2025 at 17:51 UTC
1d1b26c701893f4821ebdc6547bcd1efc392f679
4 files changed
+204
-47
packages/react-client/src/ReactFlightClient.js
+37
-5
@@ -155,6 +155,7 @@ const RESOLVED_MODEL = 'resolved_model';
155
const RESOLVED_MODULE = 'resolved_module';
156
const INITIALIZED = 'fulfilled';
157
const ERRORED = 'rejected';
158
+const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes.
159
160
type PendingChunk<T> = {
161
status: 'pending',
@@ -221,13 +222,23 @@ type ErroredChunk<T> = {
222
_debugInfo?: null | ReactDebugInfo, // DEV-only
223
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
224
};
225
+type HaltedChunk<T> = {
226
+ status: 'halted',
227
+ value: null,
228
+ reason: null,
229
+ _response: Response,
230
+ _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
231
+ _debugInfo?: null | ReactDebugInfo, // DEV-only
232
+ then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
233
+};
234
type SomeChunk<T> =
235
| PendingChunk<T>
236
| BlockedChunk<T>
237
| ResolvedModelChunk<T>
238
| ResolvedModuleChunk<T>
239
| InitializedChunk<T>
230
- | ErroredChunk<T>;
240
+ | ErroredChunk<T>
241
+ | HaltedChunk<T>;
242
243
// $FlowFixMe[missing-this-annot]
244
function ReactPromise(
@@ -311,6 +322,9 @@ ReactPromise.prototype.then = function <T>(
322
chunk.reason.push(reject);
323
}
324
break;
325
+ case HALTED: {
326
+ break;
327
+ }
328
default:
329
if (reject) {
330
reject(chunk.reason);
@@ -368,6 +382,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
382
return chunk.value;
383
case PENDING:
384
case BLOCKED:
385
+ case HALTED:
386
// eslint-disable-next-line no-throw-literal
387
throw ((chunk: any): Thenable<T>);
388
default:
@@ -1367,6 +1382,7 @@ function getOutlinedModel<T>(
1382
return chunkValue;
1383
case PENDING:
1384
case BLOCKED:
1385
+ case HALTED:
1386
return waitForReference(chunk, parentObject, key, response, map, path);
1387
default:
1388
// This is an error. Instead of erroring directly, we're going to encode this on
@@ -1470,10 +1486,6 @@ function parseModelString(
1486
}
1487
case '@': {
1488
// Promise
1473
- if (value.length === 2) {
1474
- // Infinite promise that never resolves.
1475
- return new Promise(() => {});
1476
- }
1489
const id = parseInt(value.slice(2), 16);
1490
const chunk = getChunk(response, id);
1491
if (enableProfilerTimer && enableComponentPerformanceTrack) {
@@ -1769,6 +1781,22 @@ export function createResponse(
1781
);
1782
}
1783
1784
+function resolveDebugHalt(response: Response, id: number): void {
1785
+ const chunks = response._chunks;
1786
+ let chunk = chunks.get(id);
1787
+ if (!chunk) {
1788
+ chunks.set(id, (chunk = createPendingChunk(response)));
1789
+ } else {
1790
+ }
1791
+ if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
1792
+ return;
1793
+ }
1794
+ const haltedChunk: HaltedChunk<any> = (chunk: any);
1795
+ haltedChunk.status = HALTED;
1796
+ haltedChunk.value = null;
1797
+ haltedChunk.reason = null;
1798
+}
1799
+
1800
function resolveModel(
1801
response: Response,
1802
id: number,
@@ -3339,6 +3367,10 @@ function processFullStringRow(
3367
}
3368
// Fallthrough
3369
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
3370
+ if (__DEV__ && row === '') {
3371
+ resolveDebugHalt(response, id);
3372
+ return;
3373
+ }
3374
// We assume anything else is JSON.
3375
resolveModel(response, id, row);
3376
return;
packages/react-client/src/__tests__/ReactFlight-test.js
+25
-2
@@ -3213,7 +3213,8 @@ describe('ReactFlight', () => {
3213
prop: 123,
3214
fn: foo,
3215
map: new Map([['foo', foo]]),
3216
- promise: new Promise(() => {}),
3216
+ promise: Promise.resolve('yo'),
3217
+ infinitePromise: new Promise(() => {}),
3218
});
3219
throw new Error('err');
3220
}
@@ -3258,9 +3259,14 @@ describe('ReactFlight', () => {
3259
});
3260
ownerStacks = [];
3261
3262
+ // Let the Promises resolve.
3263
+ await 0;
3264
+ await 0;
3265
+ await 0;
3266
+
3267
// The error should not actually get logged because we're not awaiting the root
3268
// so it's not thrown but the server log also shouldn't be replayed.
3263
- await ReactNoopFlightClient.read(transport);
3269
+ await ReactNoopFlightClient.read(transport, {close: true});
3270
3271
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
3272
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
@@ -3280,6 +3286,23 @@ describe('ReactFlight', () => {
3286
3287
const promise = mockConsoleLog.mock.calls[0][1].promise;
3288
expect(promise).toBeInstanceOf(Promise);
3289
+ expect(await promise).toBe('yo');
3290
+
3291
+ const infinitePromise = mockConsoleLog.mock.calls[0][1].infinitePromise;
3292
+ expect(infinitePromise).toBeInstanceOf(Promise);
3293
+ let resolved = false;
3294
+ infinitePromise.then(
3295
+ () => (resolved = true),
3296
+ x => {
3297
+ console.error(x);
3298
+ resolved = true;
3299
+ },
3300
+ );
3301
+ await 0;
3302
+ await 0;
3303
+ await 0;
3304
+ // This should not reject upon aborting the stream.
3305
+ expect(resolved).toBe(false);
3306
3307
expect(ownerStacks).toEqual(['\n in App (at **)']);
3308
});
packages/react-noop-renderer/src/ReactNoopFlightClient.js
+5
-1
@@ -24,7 +24,7 @@ type Source = Array<Uint8Array>;
24
25
const decoderOptions = {stream: true};
26
27
-const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
27
+const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({
28
createStringDecoder() {
29
return new TextDecoder();
30
},
@@ -56,6 +56,7 @@ const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
56
57
type ReadOptions = {|
58
findSourceMapURL?: FindSourceMapURLCallback,
59
+ close?: boolean,
60
|};
61
62
function read<T>(source: Source, options: ReadOptions): Thenable<T> {
@@ -74,6 +75,9 @@ function read<T>(source: Source, options: ReadOptions): Thenable<T> {
75
for (let i = 0; i < source.length; i++) {
76
processBinaryChunk(response, source[i], 0);
77
}
78
+ if (options !== undefined && options.close) {
79
+ close(response);
80
+ }
81
return getRoot(response);
82
}
83
packages/react-server/src/ReactFlightServer.js
+137
-39
@@ -677,6 +677,105 @@ export function resolveRequest(): null | Request {
677
return null;
678
}
679
680
+function serializeDebugThenable(
681
+ request: Request,
682
+ counter: {objectLimit: number},
683
+ thenable: Thenable<any>,
684
+): string {
685
+ // Like serializeThenable but for renderDebugModel
686
+ request.pendingChunks++;
687
+ const id = request.nextChunkId++;
688
+ const ref = serializePromiseID(id);
689
+ request.writtenDebugObjects.set(thenable, ref);
690
+
691
+ switch (thenable.status) {
692
+ case 'fulfilled': {
693
+ emitOutlinedDebugModelChunk(request, id, counter, thenable.value);
694
+ return ref;
695
+ }
696
+ case 'rejected': {
697
+ const x = thenable.reason;
698
+ if (
699
+ enablePostpone &&
700
+ typeof x === 'object' &&
701
+ x !== null &&
702
+ (x: any).$$typeof === REACT_POSTPONE_TYPE
703
+ ) {
704
+ const postponeInstance: Postpone = (x: any);
705
+ // We don't log this postpone.
706
+ emitPostponeChunk(request, id, postponeInstance);
707
+ } else {
708
+ // We don't log these errors since they didn't actually throw into Flight.
709
+ const digest = '';
710
+ emitErrorChunk(request, id, digest, x);
711
+ }
712
+ return ref;
713
+ }
714
+ }
715
+
716
+ let cancelled = false;
717
+
718
+ thenable.then(
719
+ value => {
720
+ if (cancelled) {
721
+ return;
722
+ }
723
+ cancelled = true;
724
+ if (request.status === ABORTING) {
725
+ emitDebugHaltChunk(request, id);
726
+ enqueueFlush(request);
727
+ return;
728
+ }
729
+ emitOutlinedDebugModelChunk(request, id, counter, value);
730
+ enqueueFlush(request);
731
+ },
732
+ reason => {
733
+ if (cancelled) {
734
+ return;
735
+ }
736
+ cancelled = true;
737
+ if (request.status === ABORTING) {
738
+ emitDebugHaltChunk(request, id);
739
+ enqueueFlush(request);
740
+ return;
741
+ }
742
+ if (
743
+ enablePostpone &&
744
+ typeof reason === 'object' &&
745
+ reason !== null &&
746
+ (reason: any).$$typeof === REACT_POSTPONE_TYPE
747
+ ) {
748
+ const postponeInstance: Postpone = (reason: any);
749
+ // We don't log this postpone.
750
+ emitPostponeChunk(request, id, postponeInstance);
751
+ } else {
752
+ // We don't log these errors since they didn't actually throw into Flight.
753
+ const digest = '';
754
+ emitErrorChunk(request, id, digest, reason);
755
+ }
756
+ enqueueFlush(request);
757
+ },
758
+ );
759
+
760
+ // We don't use scheduleMicrotask here because it doesn't actually schedule a microtask
761
+ // in all our configs which is annoying.
762
+ Promise.resolve().then(() => {
763
+ // If we don't resolve the Promise within a microtask. Leave it as hanging since we
764
+ // don't want to block the render forever on a Promise that might never resolve.
765
+ if (cancelled) {
766
+ return;
767
+ }
768
+ cancelled = true;
769
+ emitDebugHaltChunk(request, id);
770
+ enqueueFlush(request);
771
+ // Clean up the request so we don't leak this forever.
772
+ request = (null: any);
773
+ counter = (null: any);
774
+ });
775
+
776
+ return ref;
777
+}
778
+
779
function serializeThenable(
780
request: Request,
781
task: Task,
@@ -2194,10 +2293,6 @@ function serializeLazyID(id: number): string {
2293
return '$L' + id.toString(16);
2294
}
2295
2197
-function serializeInfinitePromise(): string {
2198
- return '$@';
2199
-}
2200
-
2296
function serializePromiseID(id: number): string {
2297
return '$@' + id.toString(16);
2298
}
@@ -3514,6 +3609,21 @@ function emitModelChunk(request: Request, id: number, json: string): void {
3609
request.completedRegularChunks.push(processedChunk);
3610
}
3611
3612
+function emitDebugHaltChunk(request: Request, id: number): void {
3613
+ if (!__DEV__) {
3614
+ // These errors should never make it into a build so we don't need to encode them in codes.json
3615
+ // eslint-disable-next-line react-internal/prod-error-codes
3616
+ throw new Error(
3617
+ 'emitDebugHaltChunk should never be called in production mode. This is a bug in React.',
3618
+ );
3619
+ }
3620
+ // This emits a marker that this row will never complete and should intentionally never resolve
3621
+ // even when the client stream is closed. We use just the lack of data to indicate this.
3622
+ const row = id.toString(16) + ':\n';
3623
+ const processedChunk = stringToChunk(row);
3624
+ request.completedRegularChunks.push(processedChunk);
3625
+}
3626
+
3627
function emitDebugChunk(
3628
request: Request,
3629
id: number,
@@ -3950,36 +4060,7 @@ function renderDebugModel(
4060
// $FlowFixMe[method-unbinding]
4061
if (typeof value.then === 'function') {
4062
const thenable: Thenable<any> = (value: any);
3953
- switch (thenable.status) {
3954
- case 'fulfilled': {
3955
- return serializePromiseID(
3956
- outlineDebugModel(request, counter, thenable.value),
3957
- );
3958
- }
3959
- case 'rejected': {
3960
- const x = thenable.reason;
3961
- request.pendingChunks++;
3962
- const errorId = request.nextChunkId++;
3963
- if (
3964
- enablePostpone &&
3965
- typeof x === 'object' &&
3966
- x !== null &&
3967
- (x: any).$$typeof === REACT_POSTPONE_TYPE
3968
- ) {
3969
- const postponeInstance: Postpone = (x: any);
3970
- // We don't log this postpone.
3971
- emitPostponeChunk(request, errorId, postponeInstance);
3972
- } else {
3973
- // We don't log these errors since they didn't actually throw into Flight.
3974
- const digest = '';
3975
- emitErrorChunk(request, errorId, digest, x);
3976
- }
3977
- return serializePromiseID(errorId);
3978
- }
3979
- }
3980
- // If it hasn't already resolved (and been instrumented) we just encode an infinite
3981
- // promise that will never resolve.
3982
- return serializeInfinitePromise();
4063
+ return serializeDebugThenable(request, counter, thenable);
4064
}
4065
4066
if (isArray(value)) {
@@ -4206,16 +4287,17 @@ function serializeDebugModel(
4287
}
4288
}
4289
4209
-function outlineDebugModel(
4290
+function emitOutlinedDebugModelChunk(
4291
request: Request,
4292
+ id: number,
4293
counter: {objectLimit: number},
4294
model: ReactClientValue,
4213
-): number {
4295
+): void {
4296
if (!__DEV__) {
4297
// These errors should never make it into a build so we don't need to encode them in codes.json
4298
// eslint-disable-next-line react-internal/prod-error-codes
4299
throw new Error(
4218
- 'outlineDebugModel should never be called in production mode. This is a bug in React.',
4300
+ 'emitOutlinedDebugModel should never be called in production mode. This is a bug in React.',
4301
);
4302
}
4303
@@ -4246,7 +4328,6 @@ function outlineDebugModel(
4328
}
4329
}
4330
4249
- const id = request.nextChunkId++;
4331
const prevModelRoot = debugModelRoot;
4332
debugModelRoot = model;
4333
if (typeof model === 'object' && model !== null) {
@@ -4266,10 +4347,27 @@ function outlineDebugModel(
4347
debugModelRoot = prevModelRoot;
4348
}
4349
4269
- request.pendingChunks++;
4350
const row = id.toString(16) + ':' + json + '\n';
4351
const processedChunk = stringToChunk(row);
4352
request.completedRegularChunks.push(processedChunk);
4353
+}
4354
+
4355
+function outlineDebugModel(
4356
+ request: Request,
4357
+ counter: {objectLimit: number},
4358
+ model: ReactClientValue,
4359
+): number {
4360
+ if (!__DEV__) {
4361
+ // These errors should never make it into a build so we don't need to encode them in codes.json
4362
+ // eslint-disable-next-line react-internal/prod-error-codes
4363
+ throw new Error(
4364
+ 'outlineDebugModel should never be called in production mode. This is a bug in React.',
4365
+ );
4366
+ }
4367
+
4368
+ const id = request.nextChunkId++;
4369
+ request.pendingChunks++;
4370
+ emitOutlinedDebugModelChunk(request, id, counter, model);
4371
return id;
4372
}
4373