From 402aedbd270df79a9cd39e83676dbdde3ce32613 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 27 Jun 2025 23:25:15 -0400 Subject: [PATCH 01/10] Ensure blocked debug info entries resolves in order --- .../react-client/src/ReactFlightClient.js | 92 ++++++++++++------- .../react-server/src/ReactFlightServer.js | 9 +- packages/shared/ReactTypes.js | 10 +- 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 41dd877bb4f..a3009c0fa2e 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -10,11 +10,10 @@ import type { Thenable, ReactDebugInfo, + ReactDebugInfoEntry, ReactComponentInfo, - ReactEnvironmentInfo, ReactAsyncInfo, ReactIOInfo, - ReactTimeInfo, ReactStackTrace, ReactFunctionLocation, ReactErrorInfoDev, @@ -168,6 +167,7 @@ type PendingChunk = { value: null | Array mixed)>, reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -176,6 +176,7 @@ type BlockedChunk = { value: null | Array mixed)>, reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -184,6 +185,7 @@ type ResolvedModelChunk = { value: UninitializedModel, reason: Response, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -192,6 +194,7 @@ type ResolvedModuleChunk = { value: ClientReference, reason: null, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -200,6 +203,7 @@ type InitializedChunk = { value: T, reason: null | FlightStreamController, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -210,6 +214,7 @@ type InitializedStreamChunk< value: T, reason: FlightStreamController, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void, }; @@ -218,6 +223,7 @@ type ErroredChunk = { value: null, reason: mixed, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -226,6 +232,7 @@ type HaltedChunk = { value: null, reason: null, _children: Array> | ProfilingResult, // Profiling-only + _blockedDebugInfo?: null | SomeChunk, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -247,6 +254,7 @@ function ReactPromise(status: any, value: any, reason: any) { this._children = []; } if (__DEV__) { + this._blockedDebugInfo = null; this._debugInfo = null; } } @@ -3204,12 +3212,8 @@ function initializeFakeStack( function resolveDebugInfo( response: Response, - id: number, - debugInfo: - | ReactComponentInfo - | ReactEnvironmentInfo - | ReactAsyncInfo - | ReactTimeInfo, + chunk: SomeChunk, + debugInfo: ReactDebugInfoEntry, ): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json @@ -3259,12 +3263,57 @@ function resolveDebugInfo( } } - const chunk = getChunk(response, id); const chunkDebugInfo: ReactDebugInfo = chunk._debugInfo || (chunk._debugInfo = []); chunkDebugInfo.push(debugInfo); } +function resolveDebugModel( + response: Response, + id: number, + json: UninitializedModel, +): void { + const parentChunk = getChunk(response, id); + const blockedChunk = parentChunk._blockedDebugInfo; + if (blockedChunk == null) { + // If we're not blocked on any other chunks, we can try to eagerly initialize + // this as a fast-path to avoid awaiting them. + const chunk: ResolvedModelChunk = + createResolvedModelChunk(response, json); + initializeModelChunk(chunk); + const initializedChunk: SomeChunk = chunk; + if (initializedChunk.status === INITIALIZED) { + resolveDebugInfo(response, parentChunk, initializedChunk.value); + } else { + chunk.then( + v => resolveDebugInfo(response, parentChunk, v), + e => { + // Ignore debug info errors for now. Unnecessary noise. + }, + ); + parentChunk._blockedDebugInfo = chunk; + } + } else { + // We're still waiting on a previous chunk so we can't enqueue quite yet. + const chunk: SomeChunk = createPendingChunk(response); + chunk.then( + v => resolveDebugInfo(response, parentChunk, v), + e => { + // Ignore debug info errors for now. Unnecessary noise. + }, + ); + parentChunk._blockedDebugInfo = chunk; + blockedChunk.then(function () { + if (parentChunk._blockedDebugInfo === chunk) { + // We were still the last chunk so we can now clear the queue and return + // to synchronous emitting. + parentChunk._blockedDebugInfo = null; + } + resolveModelChunk(response, chunk, json); + }); + } +} + let currentOwnerInDEV: null | ReactComponentInfo = null; function getCurrentStackInDEV(): string { if (__DEV__) { @@ -3916,30 +3965,7 @@ function processFullStringRow( } case 68 /* "D" */: { if (__DEV__) { - const chunk: ResolvedModelChunk< - | ReactComponentInfo - | ReactEnvironmentInfo - | ReactAsyncInfo - | ReactTimeInfo, - > = createResolvedModelChunk(response, row); - initializeModelChunk(chunk); - const initializedChunk: SomeChunk< - | ReactComponentInfo - | ReactEnvironmentInfo - | ReactAsyncInfo - | ReactTimeInfo, - > = chunk; - if (initializedChunk.status === INITIALIZED) { - resolveDebugInfo(response, id, initializedChunk.value); - } else { - // TODO: This is not going to resolve in the right order if there's more than one. - chunk.then( - v => resolveDebugInfo(response, id, v), - e => { - // Ignore debug info errors for now. Unnecessary noise. - }, - ); - } + resolveDebugModel(response, id, row); return; } // Fallthrough to share the error with Console entries. diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index c3c7d41612c..1828e85d77d 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -58,11 +58,10 @@ import type { FulfilledThenable, RejectedThenable, ReactDebugInfo, + ReactDebugInfoEntry, ReactComponentInfo, - ReactEnvironmentInfo, ReactIOInfo, ReactAsyncInfo, - ReactTimeInfo, ReactStackTrace, ReactCallSite, ReactFunctionLocation, @@ -4078,11 +4077,7 @@ function emitDebugHaltChunk(request: Request, id: number): void { function emitDebugChunk( request: Request, id: number, - debugInfo: - | ReactComponentInfo - | ReactAsyncInfo - | ReactEnvironmentInfo - | ReactTimeInfo, + debugInfo: ReactDebugInfoEntry, ): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index a55cb66a1e9..5c7af1d1b30 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -259,9 +259,13 @@ export type ReactTimeInfo = { +time: number, // performance.now }; -export type ReactDebugInfo = Array< - ReactComponentInfo | ReactEnvironmentInfo | ReactAsyncInfo | ReactTimeInfo, ->; +export type ReactDebugInfoEntry = + | ReactComponentInfo + | ReactEnvironmentInfo + | ReactAsyncInfo + | ReactTimeInfo; + +export type ReactDebugInfo = Array; // Intrinsic ViewTransitionInstance. This type varies by Environment whether a particular // renderer supports it. From 033685bba27eab5a49c3575da37e0f7dbcffe021 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 28 Jun 2025 21:46:08 -0400 Subject: [PATCH 02/10] Revert types These types cause an infinite loop in flow. --- packages/react-client/src/ReactFlightClient.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index a3009c0fa2e..beadd95cad0 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -167,7 +167,7 @@ type PendingChunk = { value: null | Array mixed)>, reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -176,7 +176,7 @@ type BlockedChunk = { value: null | Array mixed)>, reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -185,7 +185,7 @@ type ResolvedModelChunk = { value: UninitializedModel, reason: Response, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -194,7 +194,7 @@ type ResolvedModuleChunk = { value: ClientReference, reason: null, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -203,7 +203,7 @@ type InitializedChunk = { value: T, reason: null | FlightStreamController, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -214,7 +214,7 @@ type InitializedStreamChunk< value: T, reason: FlightStreamController, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void, }; @@ -223,7 +223,7 @@ type ErroredChunk = { value: null, reason: mixed, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -232,7 +232,7 @@ type HaltedChunk = { value: null, reason: null, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: null | SomeChunk, // DEV-only + _blockedDebugInfo?: any, // DEV-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; From 378a3234e4fe00f05ab0a982519711d3afb7d71d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 27 Jun 2025 23:39:16 -0400 Subject: [PATCH 03/10] Ensure blocked console log entries resolves in order --- .../react-client/src/ReactFlightClient.js | 92 ++++++++++++------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index beadd95cad0..892b395ec1f 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -362,6 +362,7 @@ type Response = { _debugRootTask?: null | ConsoleTask, // DEV-only _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only _debugChannel?: void | DebugChannelCallback, // DEV-only + _blockedConsole?: null | SomeChunk, // DEV-only _replayConsole: boolean, // DEV-only _rootEnvironmentName: string, // DEV-only, the requested environment name. }; @@ -2222,6 +2223,7 @@ function ResponseInstance( } this._debugFindSourceMapURL = findSourceMapURL; this._debugChannel = debugChannel; + this._blockedConsole = null; this._replayConsole = replayConsole; this._rootEnvironmentName = rootEnv; if (debugChannel) { @@ -3329,12 +3331,14 @@ function getCurrentStackInDEV(): string { const replayConsoleWithCallStack = { react_stack_bottom_frame: function ( response: Response, - methodName: string, - stackTrace: ReactStackTrace, - owner: null | ReactComponentInfo, - env: string, - args: Array, + payload: ConsoleEntry, ): void { + const methodName = payload[0]; + const stackTrace = payload[1]; + const owner = payload[2]; + const env = payload[3]; + const args = payload.slice(4); + // There really shouldn't be anything else on the stack atm. const prevStack = ReactSharedInternals.getCurrentStack; ReactSharedInternals.getCurrentStack = getCurrentStackInDEV; @@ -3372,11 +3376,7 @@ const replayConsoleWithCallStack = { const replayConsoleWithCallStackInDEV: ( response: Response, - methodName: string, - stackTrace: ReactStackTrace, - owner: null | ReactComponentInfo, - env: string, - args: Array, + payload: ConsoleEntry, ) => void = __DEV__ ? // We use this technique to trick minifiers to preserve the function name. (replayConsoleWithCallStack.react_stack_bottom_frame.bind( @@ -3384,9 +3384,17 @@ const replayConsoleWithCallStackInDEV: ( ): any) : (null: any); +type ConsoleEntry = [ + string, + ReactStackTrace, + null | ReactComponentInfo, + string, + mixed, +]; + function resolveConsoleEntry( response: Response, - value: UninitializedModel, + json: UninitializedModel, ): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json @@ -3400,27 +3408,47 @@ function resolveConsoleEntry( return; } - const payload: [ - string, - ReactStackTrace, - null | ReactComponentInfo, - string, - mixed, - ] = parseModel(response, value); - const methodName = payload[0]; - const stackTrace = payload[1]; - const owner = payload[2]; - const env = payload[3]; - const args = payload.slice(4); - - replayConsoleWithCallStackInDEV( - response, - methodName, - stackTrace, - owner, - env, - args, - ); + const blockedChunk = response._blockedConsole; + if (blockedChunk == null) { + // If we're not blocked on any other chunks, we can try to eagerly initialize + // this as a fast-path to avoid awaiting them. + const chunk: ResolvedModelChunk = createResolvedModelChunk( + response, + json, + ); + initializeModelChunk(chunk); + const initializedChunk: SomeChunk = chunk; + if (initializedChunk.status === INITIALIZED) { + replayConsoleWithCallStackInDEV(response, initializedChunk.value); + } else { + chunk.then( + v => replayConsoleWithCallStackInDEV(response, v), + e => { + // Ignore console errors for now. Unnecessary noise. + }, + ); + response._blockedConsole = chunk; + } + } else { + // We're still waiting on a previous chunk so we can't enqueue quite yet. + const chunk: SomeChunk = createPendingChunk(response); + chunk.then( + v => replayConsoleWithCallStackInDEV(response, v), + e => { + // Ignore console errors for now. Unnecessary noise. + }, + ); + response._blockedConsole = chunk; + const unblock = () => { + if (response._blockedConsole === chunk) { + // We were still the last chunk so we can now clear the queue and return + // to synchronous emitting. + response._blockedConsole = null; + } + resolveModelChunk(response, chunk, json); + }; + blockedChunk.then(unblock, unblock); + } } function initializeIOInfo(response: Response, ioInfo: ReactIOInfo): void { From b7e20be1c43fb006a1f58a31c56cba29c267f1f9 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 28 Jun 2025 10:19:55 -0400 Subject: [PATCH 04/10] Block on debug info to resolve before resolving a chunk --- .../react-client/src/ReactFlightClient.js | 119 +++++++++++------- 1 file changed, 74 insertions(+), 45 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 892b395ec1f..558900c6789 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -805,6 +805,17 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { initializingChunk = cyclicChunk; } + if (__DEV__) { + const blockingDebugChunk = chunk._blockedDebugInfo; + if ( + blockingDebugChunk != null && + (blockingDebugChunk.status === BLOCKED || + blockingDebugChunk.status === PENDING) + ) { + waitForReference(blockingDebugChunk, {}, '', response, () => {}, ['']); + } + } + try { const value: T = parseModel(response, resolvedModel); // Invoke any listeners added while resolving this model. I.e. cyclic @@ -2433,6 +2444,22 @@ function resolveStream>( chunks.set(id, createInitializedStreamChunk(response, stream, controller)); return; } + if (__DEV__) { + const blockedDebugInfo = chunk._blockedDebugInfo; + if (blockedDebugInfo != null) { + // If we're blocked on debug info, wait until it has loaded before we resolve. + const unblock = resolveStream.bind( + null, + response, + id, + stream, + controller, + ); + blockedDebugInfo.then(unblock, unblock); + return; + } + } + if (chunk.status !== PENDING) { // We already resolved. We didn't expect to see this. return; @@ -2817,6 +2844,41 @@ function resolvePostponeDev( } } +function resolveErrorModel( + response: Response, + id: number, + row: UninitializedModel, +): void { + const chunks = response._chunks; + const chunk = chunks.get(id); + if (__DEV__ && chunk) { + if (__DEV__) { + const blockedDebugInfo = chunk._blockedDebugInfo; + if (blockedDebugInfo != null) { + // If we're blocked on debug info, wait until it has loaded before we resolve. + // TODO: Handle cycle if that model depends on this one. + const unblock = resolveErrorModel.bind(null, response, id, row); + blockedDebugInfo.then(unblock, unblock); + return; + } + } + } + const errorInfo = JSON.parse(row); + let error; + if (__DEV__) { + error = resolveErrorDev(response, errorInfo); + } else { + error = resolveErrorProd(response); + } + (error: any).digest = errorInfo.digest; + const errorWithDigest: ErrorWithDigest = (error: any); + if (!chunk) { + chunks.set(id, createErrorChunk(response, errorWithDigest)); + } else { + triggerErrorOnChunk(response, chunk, errorWithDigest); + } +} + function resolveHint( response: Response, code: Code, @@ -3276,28 +3338,18 @@ function resolveDebugModel( json: UninitializedModel, ): void { const parentChunk = getChunk(response, id); - const blockedChunk = parentChunk._blockedDebugInfo; - if (blockedChunk == null) { - // If we're not blocked on any other chunks, we can try to eagerly initialize - // this as a fast-path to avoid awaiting them. - const chunk: ResolvedModelChunk = - createResolvedModelChunk(response, json); - initializeModelChunk(chunk); - const initializedChunk: SomeChunk = chunk; - if (initializedChunk.status === INITIALIZED) { - resolveDebugInfo(response, parentChunk, initializedChunk.value); - } else { - chunk.then( - v => resolveDebugInfo(response, parentChunk, v), - e => { - // Ignore debug info errors for now. Unnecessary noise. - }, - ); - parentChunk._blockedDebugInfo = chunk; - } + // If we're not blocked on any other chunks, we can try to eagerly initialize + // this as a fast-path to avoid awaiting them. + const chunk: ResolvedModelChunk = + createResolvedModelChunk(response, json); + // The previous blocked chunk is now blocking this one. + chunk._blockedDebugInfo = parentChunk._blockedDebugInfo; + initializeModelChunk(chunk); + const initializedChunk: SomeChunk = chunk; + if (initializedChunk.status === INITIALIZED) { + resolveDebugInfo(response, parentChunk, initializedChunk.value); + parentChunk._blockedDebugInfo = null; } else { - // We're still waiting on a previous chunk so we can't enqueue quite yet. - const chunk: SomeChunk = createPendingChunk(response); chunk.then( v => resolveDebugInfo(response, parentChunk, v), e => { @@ -3305,14 +3357,6 @@ function resolveDebugModel( }, ); parentChunk._blockedDebugInfo = chunk; - blockedChunk.then(function () { - if (parentChunk._blockedDebugInfo === chunk) { - // We were still the last chunk so we can now clear the queue and return - // to synchronous emitting. - parentChunk._blockedDebugInfo = null; - } - resolveModelChunk(response, chunk, json); - }); } } @@ -3956,22 +4000,7 @@ function processFullStringRow( return; } case 69 /* "E" */: { - const errorInfo = JSON.parse(row); - let error; - if (__DEV__) { - error = resolveErrorDev(response, errorInfo); - } else { - error = resolveErrorProd(response); - } - (error: any).digest = errorInfo.digest; - const errorWithDigest: ErrorWithDigest = (error: any); - const chunks = response._chunks; - const chunk = chunks.get(id); - if (!chunk) { - chunks.set(id, createErrorChunk(response, errorWithDigest)); - } else { - triggerErrorOnChunk(response, chunk, errorWithDigest); - } + resolveErrorModel(response, id, row); return; } case 84 /* "T" */: { From c19cfe74c88053fc9d7659026f0f568b79aa6014 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 16 Jul 2025 21:41:11 -0400 Subject: [PATCH 05/10] Add test --- .../__tests__/ReactFlightDOMBrowser-test.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index fb8f8b17a78..eadb4449143 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -2661,4 +2661,36 @@ describe('ReactFlightDOMBrowser', () => { '{"shared":{"id":42},"map":[[42,{"id":42}]]}', ); }); + + it('should resolve a cycle between debug info and the value it produces', async () => { + function Inner({style}) { + return
; + } + + function Component({style}) { + return ; + } + + const style = {}; + const element = ; + style.element = element; + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(element, webpackMap), + ); + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream(stream); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render(); + }); + + expect(container.innerHTML).toBe('
'); + }); }); From 9253c376e3696f1bbc6af2268e96347897ead231 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 29 Jun 2025 15:16:37 -0400 Subject: [PATCH 06/10] Lazily initialize debug chunks in a linked list --- .../react-client/src/ReactFlightClient.js | 290 ++++++++++++------ 1 file changed, 201 insertions(+), 89 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 558900c6789..02e9ddff01a 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -167,8 +167,8 @@ type PendingChunk = { value: null | Array mixed)>, reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null | SomeChunk, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type BlockedChunk = { @@ -176,8 +176,8 @@ type BlockedChunk = { value: null | Array mixed)>, reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type ResolvedModelChunk = { @@ -185,8 +185,8 @@ type ResolvedModelChunk = { value: UninitializedModel, reason: Response, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null | SomeChunk, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type ResolvedModuleChunk = { @@ -194,8 +194,8 @@ type ResolvedModuleChunk = { value: ClientReference, reason: null, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type InitializedChunk = { @@ -203,8 +203,8 @@ type InitializedChunk = { value: T, reason: null | FlightStreamController, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type InitializedStreamChunk< @@ -214,8 +214,8 @@ type InitializedStreamChunk< value: T, reason: FlightStreamController, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void, }; type ErroredChunk = { @@ -223,8 +223,8 @@ type ErroredChunk = { value: null, reason: mixed, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type HaltedChunk = { @@ -232,8 +232,8 @@ type HaltedChunk = { value: null, reason: null, _children: Array> | ProfilingResult, // Profiling-only - _blockedDebugInfo?: any, // DEV-only - _debugInfo?: null | ReactDebugInfo, // DEV-only + _debugChunk: null, // DEV-only + _debugInfo: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type SomeChunk = @@ -254,7 +254,7 @@ function ReactPromise(status: any, value: any, reason: any) { this._children = []; } if (__DEV__) { - this._blockedDebugInfo = null; + this._debugChunk = null; this._debugInfo = null; } } @@ -625,6 +625,39 @@ function triggerErrorOnChunk( } releasePendingChunk(response, chunk); const listeners = chunk.reason; + + if (__DEV__ && chunk.status === PENDING) { + // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. + if (chunk._debugChunk != null) { + const prevHandler = initializingHandler; + const prevChunk = initializingChunk; + initializingHandler = null; + const cyclicChunk: BlockedChunk = (chunk: any); + cyclicChunk.status = BLOCKED; + cyclicChunk.value = null; + cyclicChunk.reason = null; + if (enableProfilerTimer && enableComponentPerformanceTrack) { + initializingChunk = cyclicChunk; + } + try { + initializeDebugChunk(response, chunk); + chunk._debugChunk = null; + if (initializingHandler !== null) { + if (initializingHandler.errored) { + // Ignore error parsing debug info, we'll report the original error instead. + } else if (initializingHandler.deps > 0) { + // TODO: Block the resolution of the error until all the debug info has loaded. + // We currently don't have a way to throw an error after all dependencies have + // loaded because we currently treat errors as immediately cancelling the handler. + } + } + } finally { + initializingHandler = prevHandler; + initializingChunk = prevChunk; + } + } + } + const erroredChunk: ErroredChunk = (chunk: any); erroredChunk.status = ERRORED; erroredChunk.reason = error; @@ -756,6 +789,10 @@ function resolveModuleChunk( const resolvedChunk: ResolvedModuleChunk = (chunk: any); resolvedChunk.status = RESOLVED_MODULE; resolvedChunk.value = value; + if (__DEV__) { + // We don't expect to have any debug info for this row. + resolvedChunk._debugInfo = null; + } if (resolveListeners !== null) { initializeModuleChunk(resolvedChunk); wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners); @@ -779,12 +816,78 @@ type InitializationHandler = { parent: null | InitializationHandler, chunk: null | BlockedChunk, value: any, + reason: any, deps: number, errored: boolean, }; let initializingHandler: null | InitializationHandler = null; let initializingChunk: null | BlockedChunk = null; +function initializeDebugChunk( + response: Response, + chunk: ResolvedModelChunk | PendingChunk, +): void { + const debugChunk = chunk._debugChunk; + if (debugChunk !== null) { + const debugInfo = chunk._debugInfo || (chunk._debugInfo = []); + try { + if (debugChunk.status === RESOLVED_MODEL) { + // Initializing the model for the first time. + initializeModelChunk(debugChunk); + const initializedChunk = ((debugChunk: any): SomeChunk); + switch (initializedChunk.status) { + case INITIALIZED: { + debugInfo.push( + initializeDebugInfo(response, initializedChunk.value), + ); + break; + } + case BLOCKED: + case PENDING: { + debugInfo.push( + waitForReference( + initializedChunk, + debugInfo, + '' + debugInfo.length, // eslint-disable-line react-internal/safe-string-coercion + response, + initializeDebugInfo, + [''], // path + ), + ); + break; + } + default: + throw initializedChunk.reason; + } + } else { + switch (debugChunk.status) { + case INITIALIZED: { + // Already done. + break; + } + case BLOCKED: + case PENDING: { + // Signal to the caller that we need to wait. + waitForReference( + debugChunk, + {}, // noop, since we'll have already added an entry to debug info + '', // noop + response, + initializeDebugInfo, + [''], // path + ); + break; + } + default: + throw debugChunk.reason; + } + } + } catch (error) { + triggerErrorOnChunk(response, chunk, error); + } + } +} + function initializeModelChunk(chunk: ResolvedModelChunk): void { const prevHandler = initializingHandler; const prevChunk = initializingChunk; @@ -806,14 +909,9 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } if (__DEV__) { - const blockingDebugChunk = chunk._blockedDebugInfo; - if ( - blockingDebugChunk != null && - (blockingDebugChunk.status === BLOCKED || - blockingDebugChunk.status === PENDING) - ) { - waitForReference(blockingDebugChunk, {}, '', response, () => {}, ['']); - } + // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. + initializeDebugChunk(response, chunk); + chunk._debugChunk = null; } try { @@ -829,7 +927,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } if (initializingHandler !== null) { if (initializingHandler.errored) { - throw initializingHandler.value; + throw initializingHandler.reason; } if (initializingHandler.deps > 0) { // We discovered new dependencies on modules that are not yet resolved. @@ -1103,7 +1201,7 @@ function createElement( // into a Lazy so that we can still render up until that Lazy is rendered. const erroredChunk: ErroredChunk> = createErrorChunk( response, - handler.value, + handler.reason, ); if (__DEV__) { initializeElement(response, element); @@ -1160,7 +1258,7 @@ function createLazyChunkWrapper( if (__DEV__) { // Ensure we have a live array to track future debug info. const chunkDebugInfo: ReactDebugInfo = - chunk._debugInfo || (chunk._debugInfo = []); + chunk._debugInfo || (chunk._debugInfo = ([]: ReactDebugInfo)); lazyType._debugInfo = chunkDebugInfo; } return lazyType; @@ -1307,6 +1405,7 @@ function fulfillReference( const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = handler.value; + initializedChunk.reason = handler.reason; // Used by streaming chunks if (resolveListeners !== null) { wakeChunk(resolveListeners, handler.value); } @@ -1327,7 +1426,8 @@ function rejectReference( } const blockedValue = handler.value; handler.errored = true; - handler.value = error; + handler.value = null; + handler.reason = error; const chunk = handler.chunk; if (chunk === null || chunk.status !== BLOCKED) { return; @@ -1402,6 +1502,7 @@ function waitForReference( parent: null, chunk: null, value: null, + reason: null, deps: 1, errored: false, }; @@ -1488,6 +1589,7 @@ function loadServerReference, T>( parent: null, chunk: null, value: null, + reason: null, deps: 1, errored: false, }; @@ -1566,7 +1668,8 @@ function loadServerReference, T>( } const blockedValue = handler.value; handler.errored = true; - handler.value = error; + handler.value = null; + handler.reason = error; const chunk = handler.chunk; if (chunk === null || chunk.status !== BLOCKED) { return; @@ -1676,6 +1779,7 @@ function getOutlinedModel( parent: null, chunk: null, value: null, + reason: null, deps: 1, errored: false, }; @@ -1687,12 +1791,14 @@ function getOutlinedModel( // an initialization handler so that we can catch it at the nearest Element. if (initializingHandler) { initializingHandler.errored = true; - initializingHandler.value = referencedChunk.reason; + initializingHandler.value = null; + initializingHandler.reason = referencedChunk.reason; } else { initializingHandler = { parent: null, chunk: null, - value: referencedChunk.reason, + value: null, + reason: referencedChunk.reason, deps: 0, errored: true, }; @@ -1746,6 +1852,7 @@ function getOutlinedModel( parent: null, chunk: null, value: null, + reason: null, deps: 1, errored: false, }; @@ -1757,12 +1864,14 @@ function getOutlinedModel( // an initialization handler so that we can catch it at the nearest Element. if (initializingHandler) { initializingHandler.errored = true; - initializingHandler.value = chunk.reason; + initializingHandler.value = null; + initializingHandler.reason = chunk.reason; } else { initializingHandler = { parent: null, chunk: null, - value: chunk.reason, + value: null, + reason: chunk.reason, deps: 0, errored: true, }; @@ -1870,6 +1979,7 @@ function parseModelString( parent: initializingHandler, chunk: null, value: null, + reason: null, deps: 0, errored: false, }; @@ -2444,28 +2554,48 @@ function resolveStream>( chunks.set(id, createInitializedStreamChunk(response, stream, controller)); return; } - if (__DEV__) { - const blockedDebugInfo = chunk._blockedDebugInfo; - if (blockedDebugInfo != null) { - // If we're blocked on debug info, wait until it has loaded before we resolve. - const unblock = resolveStream.bind( - null, - response, - id, - stream, - controller, - ); - blockedDebugInfo.then(unblock, unblock); - return; - } - } - if (chunk.status !== PENDING) { // We already resolved. We didn't expect to see this. return; } releasePendingChunk(response, chunk); + const resolveListeners = chunk.value; + + if (__DEV__) { + // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. + if (chunk._debugChunk != null) { + const prevHandler = initializingHandler; + const prevChunk = initializingChunk; + initializingHandler = null; + const cyclicChunk: BlockedChunk = (chunk: any); + cyclicChunk.status = BLOCKED; + cyclicChunk.value = null; + cyclicChunk.reason = null; + if (enableProfilerTimer && enableComponentPerformanceTrack) { + initializingChunk = cyclicChunk; + } + try { + initializeDebugChunk(response, chunk); + chunk._debugChunk = null; + if (initializingHandler !== null) { + if (initializingHandler.errored) { + // Ignore error parsing debug info, we'll report the original error instead. + } else if (initializingHandler.deps > 0) { + // Leave blocked until we can resolve all the debug info. + initializingHandler.value = stream; + initializingHandler.reason = controller; + initializingHandler.chunk = cyclicChunk; + return; + } + } + } finally { + initializingHandler = prevHandler; + initializingChunk = prevChunk; + } + } + } + const resolvedChunk: InitializedStreamChunk = (chunk: any); resolvedChunk.status = INITIALIZED; resolvedChunk.value = stream; @@ -2851,18 +2981,6 @@ function resolveErrorModel( ): void { const chunks = response._chunks; const chunk = chunks.get(id); - if (__DEV__ && chunk) { - if (__DEV__) { - const blockedDebugInfo = chunk._blockedDebugInfo; - if (blockedDebugInfo != null) { - // If we're blocked on debug info, wait until it has loaded before we resolve. - // TODO: Handle cycle if that model depends on this one. - const unblock = resolveErrorModel.bind(null, response, id, row); - blockedDebugInfo.then(unblock, unblock); - return; - } - } - } const errorInfo = JSON.parse(row); let error; if (__DEV__) { @@ -3274,16 +3392,15 @@ function initializeFakeStack( } } -function resolveDebugInfo( +function initializeDebugInfo( response: Response, - chunk: SomeChunk, debugInfo: ReactDebugInfoEntry, -): void { +): ReactDebugInfoEntry { 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( - 'resolveDebugInfo should never be called in production mode. This is a bug in React.', + 'initializeDebugInfo should never be called in production mode. This is a bug in React.', ); } if (debugInfo.stack !== undefined) { @@ -3326,10 +3443,7 @@ function resolveDebugInfo( }; } } - - const chunkDebugInfo: ReactDebugInfo = - chunk._debugInfo || (chunk._debugInfo = []); - chunkDebugInfo.push(debugInfo); + return debugInfo; } function resolveDebugModel( @@ -3338,26 +3452,24 @@ function resolveDebugModel( json: UninitializedModel, ): void { const parentChunk = getChunk(response, id); - // If we're not blocked on any other chunks, we can try to eagerly initialize - // this as a fast-path to avoid awaiting them. - const chunk: ResolvedModelChunk = - createResolvedModelChunk(response, json); - // The previous blocked chunk is now blocking this one. - chunk._blockedDebugInfo = parentChunk._blockedDebugInfo; - initializeModelChunk(chunk); - const initializedChunk: SomeChunk = chunk; - if (initializedChunk.status === INITIALIZED) { - resolveDebugInfo(response, parentChunk, initializedChunk.value); - parentChunk._blockedDebugInfo = null; - } else { - chunk.then( - v => resolveDebugInfo(response, parentChunk, v), - e => { - // Ignore debug info errors for now. Unnecessary noise. - }, - ); - parentChunk._blockedDebugInfo = chunk; + if ( + parentChunk.status === INITIALIZED || + parentChunk.status === ERRORED || + parentChunk.status === HALTED || + parentChunk.status === BLOCKED + ) { + // We shouldn't really get debug info late. It's too late to add it after we resolved. + return; } + if (parentChunk.status === RESOLVED_MODULE) { + // We don't expect to get debug info on modules. + return; + } + const previousChunk = parentChunk._debugChunk; + const debugChunk: ResolvedModelChunk = + createResolvedModelChunk(response, json); + debugChunk._debugChunk = previousChunk; // Linked list of the debug chunks + parentChunk._debugChunk = debugChunk; } let currentOwnerInDEV: null | ReactComponentInfo = null; From 915877a3c19be5afa02279faf2ef3078fab7527a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 19 Jul 2025 18:34:44 -0400 Subject: [PATCH 07/10] Eagerly initialize debug chunks since the current consumers assume it --- packages/react-client/src/ReactFlightClient.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 02e9ddff01a..f7b6acb1a17 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -3470,6 +3470,7 @@ function resolveDebugModel( createResolvedModelChunk(response, json); debugChunk._debugChunk = previousChunk; // Linked list of the debug chunks parentChunk._debugChunk = debugChunk; + initializeDebugChunk(response, parentChunk); } let currentOwnerInDEV: null | ReactComponentInfo = null; From 28aaa3d3305559d116e0bb47a90bd584449771cb Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 19 Jul 2025 19:02:13 -0400 Subject: [PATCH 08/10] Forwarded debug info needs to come before the row completes or it won't wait for it --- packages/react-server/src/ReactFlightServer.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 1828e85d77d..b83dec914db 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1192,12 +1192,6 @@ function serializeAsyncIterable( __DEV__ ? task.debugTask : null, ); - // The task represents the Stop row. This adds a Start row. - request.pendingChunks++; - const startStreamRow = - streamTask.id.toString(16) + ':' + (isIterator ? 'x' : 'X') + '\n'; - request.completedRegularChunks.push(stringToChunk(startStreamRow)); - if (__DEV__) { const debugInfo: ?ReactDebugInfo = (iterable: any)._debugInfo; if (debugInfo) { @@ -1205,6 +1199,12 @@ function serializeAsyncIterable( } } + // The task represents the Stop row. This adds a Start row. + request.pendingChunks++; + const startStreamRow = + streamTask.id.toString(16) + ':' + (isIterator ? 'x' : 'X') + '\n'; + request.completedRegularChunks.push(stringToChunk(startStreamRow)); + function progress( entry: | {done: false, +value: ReactClientValue, ...} From 9ec0211a799961effde6b4dcbae6d2e913fd5f2a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 19 Jul 2025 19:19:02 -0400 Subject: [PATCH 09/10] Don't block chunks on debug channel if it's not wired up See #33757. --- .../react-client/src/ReactFlightClient.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index f7b6acb1a17..0ea1897856d 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -3471,6 +3471,26 @@ function resolveDebugModel( debugChunk._debugChunk = previousChunk; // Linked list of the debug chunks parentChunk._debugChunk = debugChunk; initializeDebugChunk(response, parentChunk); + if ( + __DEV__ && + ((debugChunk: any): SomeChunk).status === BLOCKED && + // TODO: This should check for the existence of the "readable" side, not the "writable". + response._debugChannel === undefined + ) { + if (json[0] === '"' && json[1] === '$') { + const path = json.slice(2, json.length - 1).split(':'); + const outlinedId = parseInt(path[0], 16); + const chunk = getChunk(response, outlinedId); + if (chunk.status === PENDING) { + // We expect the debug chunk to have been emitted earlier in the stream. It might be + // blocked on other things but chunk should no longer be pending. + // If it's still pending that suggests that it was referencing an object in the debug + // channel, but no debug channel was wired up so it's missing. In this case we can just + // drop the debug info instead of halting the whole stream. + parentChunk._debugChunk = null; + } + } + } } let currentOwnerInDEV: null | ReactComponentInfo = null; From a65bb226798eb17733cefd15e01ec57446d05b62 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 19 Jul 2025 19:53:27 -0400 Subject: [PATCH 10/10] Don't add null placeholder Instead we wait to add a debug info entry until it resolves. This will always be in order since they depend on each other. --- .../react-client/src/ReactFlightClient.js | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 0ea1897856d..5281c20966a 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -832,27 +832,35 @@ function initializeDebugChunk( const debugInfo = chunk._debugInfo || (chunk._debugInfo = []); try { if (debugChunk.status === RESOLVED_MODEL) { + // Find the index of this debug info by walking the linked list. + let idx = debugInfo.length; + let c = debugChunk._debugChunk; + while (c !== null) { + if (c.status !== INITIALIZED) { + idx++; + } + c = c._debugChunk; + } // Initializing the model for the first time. initializeModelChunk(debugChunk); const initializedChunk = ((debugChunk: any): SomeChunk); switch (initializedChunk.status) { case INITIALIZED: { - debugInfo.push( - initializeDebugInfo(response, initializedChunk.value), + debugInfo[idx] = initializeDebugInfo( + response, + initializedChunk.value, ); break; } case BLOCKED: case PENDING: { - debugInfo.push( - waitForReference( - initializedChunk, - debugInfo, - '' + debugInfo.length, // eslint-disable-line react-internal/safe-string-coercion - response, - initializeDebugInfo, - [''], // path - ), + waitForReference( + initializedChunk, + debugInfo, + '' + idx, + response, + initializeDebugInfo, + [''], // path ); break; }