From d7f13d5cf80811b6cb20788d4ee053096d2d6e5f Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 11:32:48 +0200 Subject: [PATCH 01/10] Add a test that will use the `debugChannel` for Webpack's Node client --- .../src/__tests__/ReactFlightDOMNode-test.js | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 049fa39d417..ddbf40e7ddc 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -905,4 +905,76 @@ describe('ReactFlightDOMNode', () => { // We don't really have an assertion other than to make sure // the stream doesn't hang. }); + + it('can transport debug info through a separate debug channel', async () => { + function Thrower() { + throw new Error('ssr-throw'); + } + + const ClientComponentOnTheClient = clientExports( + Thrower, + 123, + 'path/to/chunk.js', + ); + + const ClientComponentOnTheServer = clientExports(Thrower); + + function App() { + return ReactServer.createElement( + ReactServer.Suspense, + null, + ReactServer.createElement(ClientComponentOnTheClient, null), + ); + } + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App, null), + webpackMap, + ), + ); + const readable = new Stream.PassThrough(streamOptions); + + rscStream.pipe(readable); + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: { + [webpackMap[ClientComponentOnTheClient.$$id].id]: { + '*': webpackMap[ClientComponentOnTheServer.$$id], + }, + }, + moduleLoading: webpackModuleLoading, + }); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + if (__DEV__) { + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + } else { + expect(ownerStack).toBeNull(); + } + + expect(result).toContain( + 'Switched to client rendering because the server rendering errored:\n\nssr-throw', + ); + }); }); From b731e9c377ebec0f757be875ed2348d85e8eafc8 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 11:52:50 +0200 Subject: [PATCH 02/10] Support `debugChannel` option in Webpack's Node.js client --- .../src/__tests__/ReactFlightDOMNode-test.js | 21 +++++++- .../src/client/ReactFlightDOMClientNode.js | 50 ++++++++++++++----- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index ddbf40e7ddc..4b7f3ef90d2 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -927,12 +927,23 @@ describe('ReactFlightDOMNode', () => { ); } + const debugReadable = new Stream.PassThrough(streamOptions); + const rscStream = await serverAct(() => ReactServerDOMServer.renderToPipeableStream( ReactServer.createElement(App, null), webpackMap, + { + debugChannel: new Stream.Writable({ + write(chunk, encoding, callback) { + debugReadable.write(chunk, encoding); + callback(); + }, + }), + }, ), ); + const readable = new Stream.PassThrough(streamOptions); rscStream.pipe(readable); @@ -941,14 +952,20 @@ describe('ReactFlightDOMNode', () => { return use(response); } - const response = ReactServerDOMClient.createFromNodeStream(readable, { + const serverConsumerManifest = { moduleMap: { [webpackMap[ClientComponentOnTheClient.$$id].id]: { '*': webpackMap[ClientComponentOnTheServer.$$id], }, }, moduleLoading: webpackModuleLoading, - }); + }; + + const response = ReactServerDOMClient.createFromNodeStream( + readable, + serverConsumerManifest, + {debugChannel: debugReadable}, + ); let ownerStack; diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js index 38c26827e4c..f464f06499f 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js @@ -59,8 +59,38 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + // For the Node.js client we only support a single-direction debug channel. + debugChannel?: Readable, }; +function startReadingFromStream( + response: Response, + stream: Readable, + isSecondaryStream: boolean, +): void { + const streamState = createStreamState(); + + stream.on('data', chunk => { + if (typeof chunk === 'string') { + processStringChunk(response, streamState, chunk); + } else { + processBinaryChunk(response, streamState, chunk); + } + }); + + stream.on('error', error => { + reportGlobalError(response, error); + }); + + stream.on('end', () => { + // If we're the secondary stream, then we don't close the response until the + // debug channel closes. + if (!isSecondaryStream) { + close(response); + } + }); +} + function createFromNodeStream( stream: Readable, serverConsumerManifest: ServerConsumerManifest, @@ -82,18 +112,14 @@ function createFromNodeStream( ? options.environmentName : undefined, ); - const streamState = createStreamState(); - stream.on('data', chunk => { - if (typeof chunk === 'string') { - processStringChunk(response, streamState, chunk); - } else { - processBinaryChunk(response, streamState, chunk); - } - }); - stream.on('error', error => { - reportGlobalError(response, error); - }); - stream.on('end', () => close(response)); + + if (__DEV__ && options && options.debugChannel) { + startReadingFromStream(response, options.debugChannel, false); + startReadingFromStream(response, stream, true); + } else { + startReadingFromStream(response, stream, false); + } + return getRoot(response); } From 3a960cc50bba889579899825f058fce46d14aa80 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 12:38:08 +0200 Subject: [PATCH 03/10] Support `debugChannel` option in Webpack's Edge client --- .../src/__tests__/ReactFlightDOMEdge-test.js | 95 +++++++++++++++++++ .../src/client/ReactFlightDOMClientEdge.js | 35 ++++++- 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 42cff2ad51d..182b4b2f2bf 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -13,6 +13,8 @@ // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.WritableStream = + require('web-streams-polyfill/ponyfill/es6').WritableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; global.Blob = require('buffer').Blob; @@ -1968,4 +1970,97 @@ describe('ReactFlightDOMEdge', () => { const result = await readResult(ssrStream); expect(result).toEqual('
'); }); + + it('can transport debug info through a separate debug channel', async () => { + function Thrower() { + throw new Error('ssr-throw'); + } + + const ClientComponentOnTheClient = clientExports( + Thrower, + 123, + 'path/to/chunk.js', + ); + + const ClientComponentOnTheServer = clientExports(Thrower); + + function App() { + return ReactServer.createElement( + ReactServer.Suspense, + null, + ReactServer.createElement(ClientComponentOnTheClient, null), + ); + } + + let debugReadableStreamController; + + const debugReadableStream = new ReadableStream({ + start(controller) { + debugReadableStreamController = controller; + }, + }); + + const rscStream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream( + ReactServer.createElement(App, null), + webpackMap, + { + debugChannel: { + writable: new WritableStream({ + write(chunk) { + debugReadableStreamController.enqueue(chunk); + }, + }), + }, + }, + ), + ), + ); + + function ClientRoot({response}) { + return use(response); + } + + const serverConsumerManifest = { + moduleMap: { + [webpackMap[ClientComponentOnTheClient.$$id].id]: { + '*': webpackMap[ClientComponentOnTheServer.$$id], + }, + }, + moduleLoading: webpackModuleLoading, + }; + + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + serverConsumerManifest, + debugChannel: {readable: debugReadableStream}, + }); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + if (__DEV__) { + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + } else { + expect(ownerStack).toBeNull(); + } + + expect(result).toContain( + 'Switched to client rendering because the server rendering errored:\n\nssr-throw', + ); + }); }); diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js index 81f3813c433..05144cfb761 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js @@ -78,6 +78,8 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + // For the Edge client we only support a single-direction debug channel. + debugChannel?: {readable?: ReadableStream, ...}, }; function createResponseFromOptions(options: Options) { @@ -104,6 +106,7 @@ function createResponseFromOptions(options: Options) { function startReadingFromStream( response: FlightResponse, stream: ReadableStream, + isSecondaryStream: boolean, ): void { const streamState = createStreamState(); const reader = stream.getReader(); @@ -116,7 +119,11 @@ function startReadingFromStream( ... }): void | Promise { if (done) { - close(response); + // If we're the secondary stream, then we don't close the response until + // the debug channel closes. + if (!isSecondaryStream) { + close(response); + } return; } const buffer: Uint8Array = (value: any); @@ -134,7 +141,19 @@ function createFromReadableStream( options: Options, ): Thenable { const response: FlightResponse = createResponseFromOptions(options); - startReadingFromStream(response, stream); + + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + startReadingFromStream(response, options.debugChannel.readable, false); + startReadingFromStream(response, stream, true); + } else { + startReadingFromStream(response, stream, false); + } + return getRoot(response); } @@ -145,7 +164,17 @@ function createFromFetch( const response: FlightResponse = createResponseFromOptions(options); promiseForResponse.then( function (r) { - startReadingFromStream(response, (r.body: any)); + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + startReadingFromStream(response, options.debugChannel.readable, false); + startReadingFromStream(response, (r.body: any), true); + } else { + startReadingFromStream(response, (r.body: any), false); + } }, function (e) { reportGlobalError(response, e); From 5ce3a3ebf9cd0ad0ad90bf2e3c4e79677d30bed7 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 12:55:12 +0200 Subject: [PATCH 04/10] Support `debugChannel` option in Turbopack's Edge client --- .../ReactFlightTurbopackDOMEdge-test.js | 120 ++++++++++++++++-- .../src/client/ReactFlightDOMClientEdge.js | 33 ++++- 2 files changed, 141 insertions(+), 12 deletions(-) diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js index 377c27a25ab..9e9bc10865a 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js @@ -12,26 +12,28 @@ // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.WritableStream = + require('web-streams-polyfill/ponyfill/es6').WritableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setTimeout = cb => cb(); - let clientExports; let turbopackMap; let turbopackModules; let React; +let ReactServer; let ReactDOMServer; let ReactServerDOMServer; let ReactServerDOMClient; let use; +let serverAct; describe('ReactFlightTurbopackDOMEdge', () => { beforeEach(() => { jest.resetModules(); + serverAct = require('internal-test-utils').serverAct; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-turbopack/server', () => @@ -43,6 +45,7 @@ describe('ReactFlightTurbopackDOMEdge', () => { turbopackMap = TurbopackMock.turbopackMap; turbopackModules = TurbopackMock.turbopackModules; + ReactServer = require('react'); ReactServerDOMServer = require('react-server-dom-turbopack/server.edge'); jest.resetModules(); @@ -66,6 +69,15 @@ describe('ReactFlightTurbopackDOMEdge', () => { } } + function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) + ); + } + it('should allow an alternative module mapping to be used for SSR', async () => { function ClientComponent() { return Client Component; @@ -92,9 +104,8 @@ describe('ReactFlightTurbopackDOMEdge', () => { return ; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - turbopackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, turbopackMap), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { serverConsumerManifest: { @@ -107,10 +118,101 @@ describe('ReactFlightTurbopackDOMEdge', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToReadableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual('Client Component'); }); + + it('can transport debug info through a separate debug channel', async () => { + function Thrower() { + throw new Error('ssr-throw'); + } + + const ClientComponentOnTheClient = clientExports( + Thrower, + 123, + 'path/to/chunk.js', + ); + + const ClientComponentOnTheServer = clientExports(Thrower); + + function App() { + return ReactServer.createElement( + ReactServer.Suspense, + null, + ReactServer.createElement(ClientComponentOnTheClient, null), + ); + } + + let debugReadableStreamController; + + const debugReadableStream = new ReadableStream({ + start(controller) { + debugReadableStreamController = controller; + }, + }); + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + ReactServer.createElement(App, null), + turbopackMap, + { + debugChannel: { + writable: new WritableStream({ + write(chunk) { + debugReadableStreamController.enqueue(chunk); + }, + }), + }, + }, + ), + ); + + function ClientRoot({response}) { + return use(response); + } + + const serverConsumerManifest = { + moduleMap: { + [turbopackMap[ClientComponentOnTheClient.$$id].id]: { + '*': turbopackMap[ClientComponentOnTheServer.$$id], + }, + }, + moduleLoading: null, + }; + + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + serverConsumerManifest, + debugChannel: {readable: debugReadableStream}, + }); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + if (__DEV__) { + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + } else { + expect(ownerStack).toBeNull(); + } + + expect(result).toContain( + 'Switched to client rendering because the server rendering errored:\n\nssr-throw', + ); + }); }); diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js index 81f3813c433..22ac6481a94 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js @@ -104,6 +104,7 @@ function createResponseFromOptions(options: Options) { function startReadingFromStream( response: FlightResponse, stream: ReadableStream, + isSecondaryStream: boolean, ): void { const streamState = createStreamState(); const reader = stream.getReader(); @@ -116,7 +117,11 @@ function startReadingFromStream( ... }): void | Promise { if (done) { - close(response); + // If we're the secondary stream, then we don't close the response until + // the debug channel closes. + if (!isSecondaryStream) { + close(response); + } return; } const buffer: Uint8Array = (value: any); @@ -134,7 +139,19 @@ function createFromReadableStream( options: Options, ): Thenable { const response: FlightResponse = createResponseFromOptions(options); - startReadingFromStream(response, stream); + + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + startReadingFromStream(response, options.debugChannel.readable, false); + startReadingFromStream(response, stream, true); + } else { + startReadingFromStream(response, stream, false); + } + return getRoot(response); } @@ -145,7 +162,17 @@ function createFromFetch( const response: FlightResponse = createResponseFromOptions(options); promiseForResponse.then( function (r) { - startReadingFromStream(response, (r.body: any)); + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + startReadingFromStream(response, options.debugChannel.readable, false); + startReadingFromStream(response, (r.body: any), true); + } else { + startReadingFromStream(response, (r.body: any), false); + } }, function (e) { reportGlobalError(response, e); From 3473274fa37ecd55c4f8f8c58c50c9e9eb31ac45 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 13:01:07 +0200 Subject: [PATCH 05/10] Support `debugChannel` option in Turbopack's Node client --- .../ReactFlightTurbopackDOMNode-test.js | 104 ++++++++++++++++++ .../src/client/ReactFlightDOMClientNode.js | 50 +++++++-- 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js index 6b6ac31b123..3b241e710be 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js @@ -17,12 +17,17 @@ let turbopackModules; let turbopackModuleLoading; let React; let ReactDOMServer; +let ReactServer; let ReactServerDOMServer; let ReactServerDOMClient; let Stream; let use; let serverAct; +const streamOptions = { + objectMode: true, +}; + describe('ReactFlightTurbopackDOMNode', () => { beforeEach(() => { jest.resetModules(); @@ -35,6 +40,7 @@ describe('ReactFlightTurbopackDOMNode', () => { jest.mock('react-server-dom-turbopack/server', () => require('react-server-dom-turbopack/server.node'), ); + ReactServer = require('react'); ReactServerDOMServer = require('react-server-dom-turbopack/server'); const TurbopackMock = require('./utils/TurbopackMock'); @@ -75,6 +81,15 @@ describe('ReactFlightTurbopackDOMNode', () => { }); } + function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) + ); + } + it('should allow an alternative module mapping to be used for SSR', async () => { function ClientComponent() { return Client Component; @@ -130,4 +145,93 @@ describe('ReactFlightTurbopackDOMNode', () => { 'Client Component', ); }); + + it('can transport debug info through a separate debug channel', async () => { + function Thrower() { + throw new Error('ssr-throw'); + } + + const ClientComponentOnTheClient = clientExports( + Thrower, + 123, + 'path/to/chunk.js', + ); + + const ClientComponentOnTheServer = clientExports(Thrower); + + function App() { + return ReactServer.createElement( + ReactServer.Suspense, + null, + ReactServer.createElement(ClientComponentOnTheClient, null), + ); + } + + const debugReadable = new Stream.PassThrough(streamOptions); + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App, null), + turbopackMap, + { + debugChannel: new Stream.Writable({ + write(chunk, encoding, callback) { + debugReadable.write(chunk, encoding); + callback(); + }, + }), + }, + ), + ); + + const readable = new Stream.PassThrough(streamOptions); + + rscStream.pipe(readable); + + function ClientRoot({response}) { + return use(response); + } + + const serverConsumerManifest = { + moduleMap: { + [turbopackMap[ClientComponentOnTheClient.$$id].id]: { + '*': turbopackMap[ClientComponentOnTheServer.$$id], + }, + }, + moduleLoading: null, + }; + + const response = ReactServerDOMClient.createFromNodeStream( + readable, + serverConsumerManifest, + {debugChannel: debugReadable}, + ); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + if (__DEV__) { + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + } else { + expect(ownerStack).toBeNull(); + } + + expect(result).toContain( + 'Switched to client rendering because the server rendering errored:\n\nssr-throw', + ); + }); }); diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js index 38c26827e4c..f464f06499f 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js @@ -59,8 +59,38 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + // For the Node.js client we only support a single-direction debug channel. + debugChannel?: Readable, }; +function startReadingFromStream( + response: Response, + stream: Readable, + isSecondaryStream: boolean, +): void { + const streamState = createStreamState(); + + stream.on('data', chunk => { + if (typeof chunk === 'string') { + processStringChunk(response, streamState, chunk); + } else { + processBinaryChunk(response, streamState, chunk); + } + }); + + stream.on('error', error => { + reportGlobalError(response, error); + }); + + stream.on('end', () => { + // If we're the secondary stream, then we don't close the response until the + // debug channel closes. + if (!isSecondaryStream) { + close(response); + } + }); +} + function createFromNodeStream( stream: Readable, serverConsumerManifest: ServerConsumerManifest, @@ -82,18 +112,14 @@ function createFromNodeStream( ? options.environmentName : undefined, ); - const streamState = createStreamState(); - stream.on('data', chunk => { - if (typeof chunk === 'string') { - processStringChunk(response, streamState, chunk); - } else { - processBinaryChunk(response, streamState, chunk); - } - }); - stream.on('error', error => { - reportGlobalError(response, error); - }); - stream.on('end', () => close(response)); + + if (__DEV__ && options && options.debugChannel) { + startReadingFromStream(response, options.debugChannel, false); + startReadingFromStream(response, stream, true); + } else { + startReadingFromStream(response, stream, false); + } + return getRoot(response); } From 680508bf6c5e2e4492db57bf854fd5fa3252f1d6 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 13:03:19 +0200 Subject: [PATCH 06/10] Support `debugChannel` option in ESM Node client --- .../src/client/ReactFlightDOMClientNode.js | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js index 0af6d4e22e4..f4b9ab4528d 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js @@ -56,8 +56,38 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + // For the Node.js client we only support a single-direction debug channel. + debugChannel?: Readable, }; +function startReadingFromStream( + response: Response, + stream: Readable, + isSecondaryStream: boolean, +): void { + const streamState = createStreamState(); + + stream.on('data', chunk => { + if (typeof chunk === 'string') { + processStringChunk(response, streamState, chunk); + } else { + processBinaryChunk(response, streamState, chunk); + } + }); + + stream.on('error', error => { + reportGlobalError(response, error); + }); + + stream.on('end', () => { + // If we're the secondary stream, then we don't close the response until the + // debug channel closes. + if (!isSecondaryStream) { + close(response); + } + }); +} + function createFromNodeStream( stream: Readable, moduleRootPath: string, @@ -80,18 +110,14 @@ function createFromNodeStream( ? options.environmentName : undefined, ); - const streamState = createStreamState(); - stream.on('data', chunk => { - if (typeof chunk === 'string') { - processStringChunk(response, streamState, chunk); - } else { - processBinaryChunk(response, streamState, chunk); - } - }); - stream.on('error', error => { - reportGlobalError(response, error); - }); - stream.on('end', () => close(response)); + + if (__DEV__ && options && options.debugChannel) { + startReadingFromStream(response, options.debugChannel, false); + startReadingFromStream(response, stream, true); + } else { + startReadingFromStream(response, stream, false); + } + return getRoot(response); } From e78482c019e04e6761a9c3526164ac70c5577b1b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 13:04:36 +0200 Subject: [PATCH 07/10] Support `debugChannel` option in Parcel's Node client --- .../src/client/ReactFlightDOMClientNode.js | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js index 7b0507a7d2d..3e649bb4487 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js @@ -52,8 +52,38 @@ export type Options = { encodeFormAction?: EncodeFormActionCallback, replayConsoleLogs?: boolean, environmentName?: string, + // For the Node.js client we only support a single-direction debug channel. + debugChannel?: Readable, }; +function startReadingFromStream( + response: Response, + stream: Readable, + isSecondaryStream: boolean, +): void { + const streamState = createStreamState(); + + stream.on('data', chunk => { + if (typeof chunk === 'string') { + processStringChunk(response, streamState, chunk); + } else { + processBinaryChunk(response, streamState, chunk); + } + }); + + stream.on('error', error => { + reportGlobalError(response, error); + }); + + stream.on('end', () => { + // If we're the secondary stream, then we don't close the response until the + // debug channel closes. + if (!isSecondaryStream) { + close(response); + } + }); +} + export function createFromNodeStream( stream: Readable, options?: Options, @@ -72,17 +102,13 @@ export function createFromNodeStream( ? options.environmentName : undefined, ); - const streamState = createStreamState(); - stream.on('data', chunk => { - if (typeof chunk === 'string') { - processStringChunk(response, streamState, chunk); - } else { - processBinaryChunk(response, streamState, chunk); - } - }); - stream.on('error', error => { - reportGlobalError(response, error); - }); - stream.on('end', () => close(response)); + + if (__DEV__ && options && options.debugChannel) { + startReadingFromStream(response, options.debugChannel, false); + startReadingFromStream(response, stream, true); + } else { + startReadingFromStream(response, stream, false); + } + return getRoot(response); } From 10f2d5c2b72b9752bf38486692ef9eb2e01b36e9 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 13:05:59 +0200 Subject: [PATCH 08/10] Support `debugChannel` option in Parcel's Edge client --- .../src/client/ReactFlightDOMClientEdge.js | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js index 406ee54f723..dece127c3bc 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js @@ -76,6 +76,8 @@ export type Options = { temporaryReferences?: TemporaryReferenceSet, replayConsoleLogs?: boolean, environmentName?: string, + // For the Edge client we only support a single-direction debug channel. + debugChannel?: {readable?: ReadableStream, ...}, }; function createResponseFromOptions(options?: Options) { @@ -100,6 +102,7 @@ function createResponseFromOptions(options?: Options) { function startReadingFromStream( response: FlightResponse, stream: ReadableStream, + isSecondaryStream: boolean, ): void { const streamState = createStreamState(); const reader = stream.getReader(); @@ -112,7 +115,11 @@ function startReadingFromStream( ... }): void | Promise { if (done) { - close(response); + // If we're the secondary stream, then we don't close the response until + // the debug channel closes. + if (!isSecondaryStream) { + close(response); + } return; } const buffer: Uint8Array = (value: any); @@ -130,7 +137,19 @@ export function createFromReadableStream( options?: Options, ): Thenable { const response: FlightResponse = createResponseFromOptions(options); - startReadingFromStream(response, stream); + + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + startReadingFromStream(response, options.debugChannel.readable, false); + startReadingFromStream(response, stream, true); + } else { + startReadingFromStream(response, stream, false); + } + return getRoot(response); } @@ -141,7 +160,17 @@ export function createFromFetch( const response: FlightResponse = createResponseFromOptions(options); promiseForResponse.then( function (r) { - startReadingFromStream(response, (r.body: any)); + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + startReadingFromStream(response, options.debugChannel.readable, false); + startReadingFromStream(response, (r.body: any), true); + } else { + startReadingFromStream(response, (r.body: any), false); + } }, function (e) { reportGlobalError(response, e); From 87f2b0cabb963196898e34cacac125179c81856c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 13:16:57 +0200 Subject: [PATCH 09/10] Gate on `__DEV__` --- .../src/__tests__/ReactFlightTurbopackDOMEdge-test.js | 7 ++----- .../src/__tests__/ReactFlightTurbopackDOMNode-test.js | 7 ++----- .../src/__tests__/ReactFlightDOMEdge-test.js | 7 ++----- .../src/__tests__/ReactFlightDOMNode-test.js | 7 ++----- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js index 9e9bc10865a..31df1b2a5fe 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js @@ -125,6 +125,7 @@ describe('ReactFlightTurbopackDOMEdge', () => { expect(result).toEqual('Client Component'); }); + // @gate __DEV__ it('can transport debug info through a separate debug channel', async () => { function Thrower() { throw new Error('ssr-throw'); @@ -205,11 +206,7 @@ describe('ReactFlightTurbopackDOMEdge', () => { const result = await readResult(ssrStream); - if (__DEV__) { - expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); - } else { - expect(ownerStack).toBeNull(); - } + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); expect(result).toContain( 'Switched to client rendering because the server rendering errored:\n\nssr-throw', diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js index 3b241e710be..8d310455224 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js @@ -146,6 +146,7 @@ describe('ReactFlightTurbopackDOMNode', () => { ); }); + // @gate __DEV__ it('can transport debug info through a separate debug channel', async () => { function Thrower() { throw new Error('ssr-throw'); @@ -224,11 +225,7 @@ describe('ReactFlightTurbopackDOMNode', () => { const result = await readResult(ssrStream); - if (__DEV__) { - expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); - } else { - expect(ownerStack).toBeNull(); - } + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); expect(result).toContain( 'Switched to client rendering because the server rendering errored:\n\nssr-throw', diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 182b4b2f2bf..e987fa45ae3 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -1971,6 +1971,7 @@ describe('ReactFlightDOMEdge', () => { expect(result).toEqual('
'); }); + // @gate __DEV__ it('can transport debug info through a separate debug channel', async () => { function Thrower() { throw new Error('ssr-throw'); @@ -2053,11 +2054,7 @@ describe('ReactFlightDOMEdge', () => { const result = await readResult(ssrStream); - if (__DEV__) { - expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); - } else { - expect(ownerStack).toBeNull(); - } + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); expect(result).toContain( 'Switched to client rendering because the server rendering errored:\n\nssr-throw', diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 4b7f3ef90d2..fd2a806f9ee 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -906,6 +906,7 @@ describe('ReactFlightDOMNode', () => { // the stream doesn't hang. }); + // @gate __DEV__ it('can transport debug info through a separate debug channel', async () => { function Thrower() { throw new Error('ssr-throw'); @@ -984,11 +985,7 @@ describe('ReactFlightDOMNode', () => { const result = await readResult(ssrStream); - if (__DEV__) { - expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); - } else { - expect(ownerStack).toBeNull(); - } + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); expect(result).toContain( 'Switched to client rendering because the server rendering errored:\n\nssr-throw', From d64fe0fa1b19445471ba0180b64ec4a119c1ccb8 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 20 Aug 2025 13:42:11 +0200 Subject: [PATCH 10/10] Add missing `debugChannel` option to `Options` type --- .../src/client/ReactFlightDOMClientEdge.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js index 22ac6481a94..05144cfb761 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js @@ -78,6 +78,8 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + // For the Edge client we only support a single-direction debug channel. + debugChannel?: {readable?: ReadableStream, ...}, }; function createResponseFromOptions(options: Options) {