From 1d1b26c701893f4821ebdc6547bcd1efc392f679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 22 Jun 2025 17:51:31 -0400 Subject: [PATCH] [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. --- .../react-client/src/ReactFlightClient.js | 42 ++++- .../src/__tests__/ReactFlight-test.js | 27 ++- .../src/ReactNoopFlightClient.js | 6 +- .../react-server/src/ReactFlightServer.js | 176 ++++++++++++++---- 4 files changed, 204 insertions(+), 47 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index cfe82093df..d5c3fc7b5c 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -155,6 +155,7 @@ const RESOLVED_MODEL = 'resolved_model'; const RESOLVED_MODULE = 'resolved_module'; const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; +const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes. type PendingChunk = { status: 'pending', @@ -221,13 +222,23 @@ type ErroredChunk = { _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; +type HaltedChunk = { + status: 'halted', + value: null, + reason: null, + _response: Response, + _children: Array> | ProfilingResult, // Profiling-only + _debugInfo?: null | ReactDebugInfo, // DEV-only + then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, +}; type SomeChunk = | PendingChunk | BlockedChunk | ResolvedModelChunk | ResolvedModuleChunk | InitializedChunk - | ErroredChunk; + | ErroredChunk + | HaltedChunk; // $FlowFixMe[missing-this-annot] function ReactPromise( @@ -311,6 +322,9 @@ ReactPromise.prototype.then = function ( chunk.reason.push(reject); } break; + case HALTED: { + break; + } default: if (reject) { reject(chunk.reason); @@ -368,6 +382,7 @@ function readChunk(chunk: SomeChunk): T { return chunk.value; case PENDING: case BLOCKED: + case HALTED: // eslint-disable-next-line no-throw-literal throw ((chunk: any): Thenable); default: @@ -1367,6 +1382,7 @@ function getOutlinedModel( return chunkValue; case PENDING: case BLOCKED: + case HALTED: return waitForReference(chunk, parentObject, key, response, map, path); default: // This is an error. Instead of erroring directly, we're going to encode this on @@ -1470,10 +1486,6 @@ function parseModelString( } case '@': { // Promise - if (value.length === 2) { - // Infinite promise that never resolves. - return new Promise(() => {}); - } const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); if (enableProfilerTimer && enableComponentPerformanceTrack) { @@ -1769,6 +1781,22 @@ export function createResponse( ); } +function resolveDebugHalt(response: Response, id: number): void { + const chunks = response._chunks; + let chunk = chunks.get(id); + if (!chunk) { + chunks.set(id, (chunk = createPendingChunk(response))); + } else { + } + if (chunk.status !== PENDING && chunk.status !== BLOCKED) { + return; + } + const haltedChunk: HaltedChunk = (chunk: any); + haltedChunk.status = HALTED; + haltedChunk.value = null; + haltedChunk.reason = null; +} + function resolveModel( response: Response, id: number, @@ -3339,6 +3367,10 @@ function processFullStringRow( } // Fallthrough default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ { + if (__DEV__ && row === '') { + resolveDebugHalt(response, id); + return; + } // We assume anything else is JSON. resolveModel(response, id, row); return; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index c111acd428..4c7aecc269 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -3213,7 +3213,8 @@ describe('ReactFlight', () => { prop: 123, fn: foo, map: new Map([['foo', foo]]), - promise: new Promise(() => {}), + promise: Promise.resolve('yo'), + infinitePromise: new Promise(() => {}), }); throw new Error('err'); } @@ -3258,9 +3259,14 @@ describe('ReactFlight', () => { }); ownerStacks = []; + // Let the Promises resolve. + await 0; + await 0; + await 0; + // The error should not actually get logged because we're not awaiting the root // so it's not thrown but the server log also shouldn't be replayed. - await ReactNoopFlightClient.read(transport); + await ReactNoopFlightClient.read(transport, {close: true}); expect(mockConsoleLog).toHaveBeenCalledTimes(1); expect(mockConsoleLog.mock.calls[0][0]).toBe('hi'); @@ -3280,6 +3286,23 @@ describe('ReactFlight', () => { const promise = mockConsoleLog.mock.calls[0][1].promise; expect(promise).toBeInstanceOf(Promise); + expect(await promise).toBe('yo'); + + const infinitePromise = mockConsoleLog.mock.calls[0][1].infinitePromise; + expect(infinitePromise).toBeInstanceOf(Promise); + let resolved = false; + infinitePromise.then( + () => (resolved = true), + x => { + console.error(x); + resolved = true; + }, + ); + await 0; + await 0; + await 0; + // This should not reject upon aborting the stream. + expect(resolved).toBe(false); expect(ownerStacks).toEqual(['\n in App (at **)']); }); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 3f71a13872..c2be176b1a 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -24,7 +24,7 @@ type Source = Array; const decoderOptions = {stream: true}; -const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({ +const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({ createStringDecoder() { return new TextDecoder(); }, @@ -56,6 +56,7 @@ const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({ type ReadOptions = {| findSourceMapURL?: FindSourceMapURLCallback, + close?: boolean, |}; function read(source: Source, options: ReadOptions): Thenable { @@ -74,6 +75,9 @@ function read(source: Source, options: ReadOptions): Thenable { for (let i = 0; i < source.length; i++) { processBinaryChunk(response, source[i], 0); } + if (options !== undefined && options.close) { + close(response); + } return getRoot(response); } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 81df8d781a..c55e42cc9d 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -677,6 +677,105 @@ export function resolveRequest(): null | Request { return null; } +function serializeDebugThenable( + request: Request, + counter: {objectLimit: number}, + thenable: Thenable, +): string { + // Like serializeThenable but for renderDebugModel + request.pendingChunks++; + const id = request.nextChunkId++; + const ref = serializePromiseID(id); + request.writtenDebugObjects.set(thenable, ref); + + switch (thenable.status) { + case 'fulfilled': { + emitOutlinedDebugModelChunk(request, id, counter, thenable.value); + return ref; + } + case 'rejected': { + const x = thenable.reason; + if ( + enablePostpone && + typeof x === 'object' && + x !== null && + (x: any).$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (x: any); + // We don't log this postpone. + emitPostponeChunk(request, id, postponeInstance); + } else { + // We don't log these errors since they didn't actually throw into Flight. + const digest = ''; + emitErrorChunk(request, id, digest, x); + } + return ref; + } + } + + let cancelled = false; + + thenable.then( + value => { + if (cancelled) { + return; + } + cancelled = true; + if (request.status === ABORTING) { + emitDebugHaltChunk(request, id); + enqueueFlush(request); + return; + } + emitOutlinedDebugModelChunk(request, id, counter, value); + enqueueFlush(request); + }, + reason => { + if (cancelled) { + return; + } + cancelled = true; + if (request.status === ABORTING) { + emitDebugHaltChunk(request, id); + enqueueFlush(request); + return; + } + if ( + enablePostpone && + typeof reason === 'object' && + reason !== null && + (reason: any).$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (reason: any); + // We don't log this postpone. + emitPostponeChunk(request, id, postponeInstance); + } else { + // We don't log these errors since they didn't actually throw into Flight. + const digest = ''; + emitErrorChunk(request, id, digest, reason); + } + enqueueFlush(request); + }, + ); + + // We don't use scheduleMicrotask here because it doesn't actually schedule a microtask + // in all our configs which is annoying. + Promise.resolve().then(() => { + // If we don't resolve the Promise within a microtask. Leave it as hanging since we + // don't want to block the render forever on a Promise that might never resolve. + if (cancelled) { + return; + } + cancelled = true; + emitDebugHaltChunk(request, id); + enqueueFlush(request); + // Clean up the request so we don't leak this forever. + request = (null: any); + counter = (null: any); + }); + + return ref; +} + function serializeThenable( request: Request, task: Task, @@ -2194,10 +2293,6 @@ function serializeLazyID(id: number): string { return '$L' + id.toString(16); } -function serializeInfinitePromise(): string { - return '$@'; -} - function serializePromiseID(id: number): string { return '$@' + id.toString(16); } @@ -3514,6 +3609,21 @@ function emitModelChunk(request: Request, id: number, json: string): void { request.completedRegularChunks.push(processedChunk); } +function emitDebugHaltChunk(request: Request, id: number): void { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'emitDebugHaltChunk should never be called in production mode. This is a bug in React.', + ); + } + // This emits a marker that this row will never complete and should intentionally never resolve + // even when the client stream is closed. We use just the lack of data to indicate this. + const row = id.toString(16) + ':\n'; + const processedChunk = stringToChunk(row); + request.completedRegularChunks.push(processedChunk); +} + function emitDebugChunk( request: Request, id: number, @@ -3950,36 +4060,7 @@ function renderDebugModel( // $FlowFixMe[method-unbinding] if (typeof value.then === 'function') { const thenable: Thenable = (value: any); - switch (thenable.status) { - case 'fulfilled': { - return serializePromiseID( - outlineDebugModel(request, counter, thenable.value), - ); - } - case 'rejected': { - const x = thenable.reason; - request.pendingChunks++; - const errorId = request.nextChunkId++; - if ( - enablePostpone && - typeof x === 'object' && - x !== null && - (x: any).$$typeof === REACT_POSTPONE_TYPE - ) { - const postponeInstance: Postpone = (x: any); - // We don't log this postpone. - emitPostponeChunk(request, errorId, postponeInstance); - } else { - // We don't log these errors since they didn't actually throw into Flight. - const digest = ''; - emitErrorChunk(request, errorId, digest, x); - } - return serializePromiseID(errorId); - } - } - // If it hasn't already resolved (and been instrumented) we just encode an infinite - // promise that will never resolve. - return serializeInfinitePromise(); + return serializeDebugThenable(request, counter, thenable); } if (isArray(value)) { @@ -4206,16 +4287,17 @@ function serializeDebugModel( } } -function outlineDebugModel( +function emitOutlinedDebugModelChunk( request: Request, + id: number, counter: {objectLimit: number}, model: ReactClientValue, -): number { +): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json // eslint-disable-next-line react-internal/prod-error-codes throw new Error( - 'outlineDebugModel should never be called in production mode. This is a bug in React.', + 'emitOutlinedDebugModel should never be called in production mode. This is a bug in React.', ); } @@ -4246,7 +4328,6 @@ function outlineDebugModel( } } - const id = request.nextChunkId++; const prevModelRoot = debugModelRoot; debugModelRoot = model; if (typeof model === 'object' && model !== null) { @@ -4266,10 +4347,27 @@ function outlineDebugModel( debugModelRoot = prevModelRoot; } - request.pendingChunks++; const row = id.toString(16) + ':' + json + '\n'; const processedChunk = stringToChunk(row); request.completedRegularChunks.push(processedChunk); +} + +function outlineDebugModel( + request: Request, + counter: {objectLimit: number}, + model: ReactClientValue, +): number { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'outlineDebugModel should never be called in production mode. This is a bug in React.', + ); + } + + const id = request.nextChunkId++; + request.pendingChunks++; + emitOutlinedDebugModelChunk(request, id, counter, model); return id; }