@samitouri / QOS-React-2 / commits / 1d1b26c701

[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