From 9c2e2b8475fb9d55fe47f55b007fba2d474e06f4 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 27 Aug 2025 13:50:19 +0200 Subject: [PATCH] [Flight] Don't drop debug info if there's only a readable debug channel (#34304) When the Flight Client is waiting for pending debug chunks, it drops the debug info if there is no writable side of the debug channel defined. However, it should instead check if there's no readable side defined. Fixing this is not only important for browser clients that don't want or need a return channel, but it's also crucial for server-side rendering, because the Node and Edge clients only accept a readable side of the debug channel. So they can't even define a noop writable side as a workaround. --- .../react-client/src/ReactFlightClient.js | 48 +++++--- .../src/client/ReactFlightDOMClientBrowser.js | 25 ++-- .../src/client/ReactFlightDOMClientNode.js | 12 +- .../src/client/ReactFlightDOMClientBrowser.js | 82 ++++++------- .../src/client/ReactFlightDOMClientEdge.js | 14 ++- .../src/client/ReactFlightDOMClientNode.js | 11 +- .../ReactFlightTurbopackDOMBrowser-test.js | 84 +++++++++++++ .../ReactFlightTurbopackDOMEdge-test.js | 97 +++++++++++++++ .../ReactFlightTurbopackDOMNode-test.js | 110 ++++++++++++++++- .../src/client/ReactFlightDOMClientBrowser.js | 25 ++-- .../src/client/ReactFlightDOMClientEdge.js | 10 ++ .../src/client/ReactFlightDOMClientNode.js | 12 +- .../__tests__/ReactFlightDOMBrowser-test.js | 79 +++++++++++++ .../src/__tests__/ReactFlightDOMEdge-test.js | 99 ++++++++++++++++ .../src/__tests__/ReactFlightDOMNode-test.js | 111 +++++++++++++++++- .../src/client/ReactFlightDOMClientBrowser.js | 25 ++-- .../src/client/ReactFlightDOMClientEdge.js | 12 +- .../src/client/ReactFlightDOMClientNode.js | 12 +- 18 files changed, 763 insertions(+), 105 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 0e22e3f294f..61a67bce9d8 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -341,6 +341,11 @@ export type FindSourceMapURLCallback = ( export type DebugChannelCallback = (message: string) => void; +export type DebugChannel = { + hasReadable: boolean, + callback: DebugChannelCallback | null, +}; + type Response = { _bundlerConfig: ServerConsumerModuleMap, _serverReferenceConfig: null | ServerManifest, @@ -362,7 +367,7 @@ type Response = { _debugRootStack?: null | Error, // DEV-only _debugRootTask?: null | ConsoleTask, // DEV-only _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only - _debugChannel?: void | DebugChannelCallback, // DEV-only + _debugChannel?: void | DebugChannel, // DEV-only _blockedConsole?: null | SomeChunk, // DEV-only _replayConsole: boolean, // DEV-only _rootEnvironmentName: string, // DEV-only, the requested environment name. @@ -404,16 +409,16 @@ function getWeakResponse(response: Response): WeakResponse { } } -function cleanupDebugChannel(debugChannel: DebugChannelCallback): void { - // When a Response gets GC:ed because nobody is referring to any of the objects that lazily - // loads from the Response anymore, then we can close the debug channel. - debugChannel(''); +function closeDebugChannel(debugChannel: DebugChannel): void { + if (debugChannel.callback) { + debugChannel.callback(''); + } } // If FinalizationRegistry doesn't exist, we cannot use the debugChannel. const debugChannelRegistry = __DEV__ && typeof FinalizationRegistry === 'function' - ? new FinalizationRegistry(cleanupDebugChannel) + ? new FinalizationRegistry(closeDebugChannel) : null; function readChunk(chunk: SomeChunk): T { @@ -1007,7 +1012,7 @@ export function reportGlobalError( if (debugChannel !== undefined) { // If we don't have any more ways of reading data, we don't have to send any // more neither. So we close the writable side. - debugChannel(''); + closeDebugChannel(debugChannel); response._debugChannel = undefined; } } @@ -1494,8 +1499,8 @@ function waitForReference( ): T { if ( __DEV__ && - // TODO: This should check for the existence of the "readable" side, not the "writable". - response._debugChannel === undefined + (response._debugChannel === undefined || + !response._debugChannel.hasReadable) ) { if ( referencedChunk.status === PENDING && @@ -2262,15 +2267,16 @@ function parseModelString( case 'Y': { if (__DEV__) { if (value.length > 2) { - const debugChannel = response._debugChannel; - if (debugChannel) { + const debugChannelCallback = + response._debugChannel && response._debugChannel.callback; + if (debugChannelCallback) { if (value[2] === '@') { // This is a deferred Promise. const ref = value.slice(3); // We assume this doesn't have a path just id. const id = parseInt(ref, 16); if (!response._chunks.has(id)) { // We haven't seen this id before. Query the server to start sending it. - debugChannel('P:' + ref); + debugChannelCallback('P:' + ref); } // Start waiting. This now creates a pending chunk if it doesn't already exist. // This is the actual Promise we're waiting for. @@ -2280,7 +2286,7 @@ function parseModelString( const id = parseInt(ref, 16); if (!response._chunks.has(id)) { // We haven't seen this id before. Query the server to start sending it. - debugChannel('Q:' + ref); + debugChannelCallback('Q:' + ref); } // Start waiting. This now creates a pending chunk if it doesn't already exist. const chunk = getChunk(response, id); @@ -2358,7 +2364,7 @@ function ResponseInstance( findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only - debugChannel: void | DebugChannelCallback, // DEV-only + debugChannel: void | DebugChannel, // DEV-only ) { const chunks: Map> = new Map(); this._bundlerConfig = bundlerConfig; @@ -2420,10 +2426,14 @@ function ResponseInstance( this._rootEnvironmentName = rootEnv; if (debugChannel) { if (debugChannelRegistry === null) { - // We can't safely clean things up later, so we immediately close the debug channel. - debugChannel(''); + // We can't safely clean things up later, so we immediately close the + // debug channel. + closeDebugChannel(debugChannel); this._debugChannel = undefined; } else { + // When a Response gets GC:ed because nobody is referring to any of the + // objects that lazily load from the Response anymore, then we can close + // the debug channel. debugChannelRegistry.register(this, debugChannel); } } @@ -2451,7 +2461,7 @@ export function createResponse( findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only - debugChannel: void | DebugChannelCallback, // DEV-only + debugChannel: void | DebugChannel, // DEV-only ): WeakResponse { return getWeakResponse( // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors @@ -3545,8 +3555,8 @@ function resolveDebugModel( if ( __DEV__ && ((debugChunk: any): SomeChunk).status === BLOCKED && - // TODO: This should check for the existence of the "readable" side, not the "writable". - response._debugChannel === undefined + (response._debugChannel === undefined || + !response._debugChannel.hasReadable) ) { if (json[0] === '"' && json[1] === '$') { const path = json.slice(2, json.length - 1).split(':'); diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js index bd0e0dfa143..d61f1323105 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js @@ -10,9 +10,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type { - Response as FlightResponse, - FindSourceMapURLCallback, + DebugChannel, DebugChannelCallback, + FindSourceMapURLCallback, + Response as FlightResponse, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -72,6 +73,19 @@ function createDebugCallbackFromWritableStream( } function createResponseFromOptions(options: void | Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream( + options.debugChannel.writable, + ) + : null, + } + : undefined; + return createResponse( options && options.moduleBaseURL ? options.moduleBaseURL : '', null, @@ -89,12 +103,7 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, - __DEV__ && - options && - options.debugChannel !== undefined && - options.debugChannel.writable !== undefined - ? createDebugCallbackFromWritableStream(options.debugChannel.writable) - : undefined, + debugChannel, ); } diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js index 206ba2faea9..3500b3f41f2 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js @@ -10,8 +10,9 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; import type { - Response, + DebugChannel, FindSourceMapURLCallback, + Response, } from 'react-client/src/ReactFlightClient'; import type {Readable} from 'stream'; @@ -88,6 +89,14 @@ function createFromNodeStream( moduleBaseURL: string, options?: Options, ): Thenable { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: null, + } + : undefined; + const response: Response = createResponse( moduleRootPath, null, @@ -103,6 +112,7 @@ function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + debugChannel, ); if (__DEV__ && options && options.debugChannel) { diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js index c118077a086..808b49d9d75 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js @@ -9,8 +9,9 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type { - Response as FlightResponse, + DebugChannel, DebugChannelCallback, + Response as FlightResponse, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; import type {ServerReferenceId} from '../client/ReactFlightClientConfigBundlerParcel'; @@ -99,6 +100,39 @@ function createDebugCallbackFromWritableStream( }; } +function createResponseFromOptions(options: void | Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream( + options.debugChannel.writable, + ) + : null, + } + : undefined; + + return createResponse( + null, // bundlerConfig + null, // serverReferenceConfig + null, // moduleLoading + callCurrentServerCallback, + undefined, // encodeFormAction + undefined, // nonce + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + __DEV__ ? findSourceMapURL : undefined, + __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true + __DEV__ && options && options.environmentName + ? options.environmentName + : undefined, + debugChannel, + ); +} + function startReadingFromUniversalStream( response: FlightResponse, stream: ReadableStream, @@ -176,28 +210,7 @@ export function createFromReadableStream( stream: ReadableStream, options?: Options, ): Thenable { - const response: FlightResponse = createResponse( - null, // bundlerConfig - null, // serverReferenceConfig - null, // moduleLoading - callCurrentServerCallback, - undefined, // encodeFormAction - undefined, // nonce - options && options.temporaryReferences - ? options.temporaryReferences - : undefined, - __DEV__ ? findSourceMapURL : undefined, - __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true - __DEV__ && options && options.environmentName - ? options.environmentName - : undefined, - __DEV__ && - options && - options.debugChannel !== undefined && - options.debugChannel.writable !== undefined - ? createDebugCallbackFromWritableStream(options.debugChannel.writable) - : undefined, - ); + const response: FlightResponse = createResponseFromOptions(options); if ( __DEV__ && options && @@ -226,28 +239,7 @@ export function createFromFetch( promiseForResponse: Promise, options?: Options, ): Thenable { - const response: FlightResponse = createResponse( - null, // bundlerConfig - null, // serverReferenceConfig - null, // moduleLoading - callCurrentServerCallback, - undefined, // encodeFormAction - undefined, // nonce - options && options.temporaryReferences - ? options.temporaryReferences - : undefined, - __DEV__ ? findSourceMapURL : undefined, - __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true - __DEV__ && options && options.environmentName - ? options.environmentName - : undefined, - __DEV__ && - options && - options.debugChannel !== undefined && - options.debugChannel.writable !== undefined - ? createDebugCallbackFromWritableStream(options.debugChannel.writable) - : undefined, - ); + const response: FlightResponse = createResponseFromOptions(options); promiseForResponse.then( function (r) { if ( diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js index 03c050f4cba..54c72968c22 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + DebugChannel, + Response as FlightResponse, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; import { @@ -81,6 +84,14 @@ export type Options = { }; function createResponseFromOptions(options?: Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: null, + } + : undefined; + return createResponse( null, // bundlerConfig null, // serverReferenceConfig @@ -96,6 +107,7 @@ function createResponseFromOptions(options?: Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + debugChannel, ); } diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js index 42e71c2c8a7..b513fd3faca 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js @@ -8,7 +8,7 @@ */ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type {DebugChannel, Response} from 'react-client/src/ReactFlightClient'; import type {Readable} from 'stream'; import { @@ -82,6 +82,14 @@ export function createFromNodeStream( stream: Readable, options?: Options, ): Thenable { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: null, + } + : undefined; + const response: Response = createResponse( null, // bundlerConfig null, // serverReferenceConfig @@ -95,6 +103,7 @@ export function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + debugChannel, ); if (__DEV__ && options && options.debugChannel) { diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js index a62ce7b8e74..d9061d8e442 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js @@ -19,10 +19,12 @@ global.WritableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; +let clientExports; let React; let ReactDOMClient; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactServer; let ReactServerScheduler; let act; let serverAct; @@ -39,10 +41,13 @@ describe('ReactFlightTurbopackDOMBrowser', () => { // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); + ReactServer = require('react'); + jest.mock('react-server-dom-turbopack/server', () => require('react-server-dom-turbopack/server.browser'), ); const TurbopackMock = require('./utils/TurbopackMock'); + clientExports = TurbopackMock.clientExports; turbopackMap = TurbopackMock.turbopackMap; ReactServerDOMServer = require('react-server-dom-turbopack/server.browser'); @@ -77,6 +82,15 @@ describe('ReactFlightTurbopackDOMBrowser', () => { }); } + function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) + ); + } + it('should resolve HTML using W3C streams', async () => { function Text({children}) { return {children}; @@ -163,4 +177,74 @@ describe('ReactFlightTurbopackDOMBrowser', () => { expect(container.innerHTML).toBe('
Hi
'); }); + + it('can transport debug info through a dedicated debug channel', async () => { + let ownerStack; + + const ClientComponent = clientExports(() => { + ownerStack = React.captureOwnerStack ? React.captureOwnerStack() : null; + return

Hi

; + }); + + function App() { + return ReactServer.createElement( + ReactServer.Suspense, + null, + ReactServer.createElement(ClientComponent, 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); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ); + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + replayConsoleLogs: true, + debugChannel: { + readable: debugReadableStream, + // Explicitly not defining a writable side here. Its presence was + // previously used as a condition to wait for referenced debug chunks. + }, + }); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render(); + }); + + if (__DEV__) { + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + } + + expect(container.innerHTML).toBe('

Hi

'); + }); }); 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 be3e7e476d9..ec2d42201b9 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js @@ -241,4 +241,101 @@ describe('ReactFlightTurbopackDOMEdge', () => { 'Switched to client rendering because the server rendering errored:\n\nssr-throw', ); }); + + // @gate __DEV__ + it('can transport debug info through a slow 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); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ); + + 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: + // Create a delayed stream to simulate that the debug stream might + // be transported slower than the RSC stream, which must not lead to + // missing debug info. + createDelayedStream(debugReadableStream), + }, + }); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + 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 1fcf52fde44..59e8ea39470 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js @@ -91,15 +91,19 @@ describe('ReactFlightTurbopackDOMNode', () => { } function createDelayedStream() { - return new Stream.Transform({ + let resolveDelayedStream; + const promise = new Promise(resolve => (resolveDelayedStream = resolve)); + const delayedStream = new Stream.Transform({ ...streamOptions, transform(chunk, encoding, callback) { - setTimeout(() => { + // Artificially delay pushing the chunk. + promise.then(() => { this.push(chunk); callback(); }); }, }); + return {delayedStream, resolveDelayedStream}; } it('should allow an alternative module mapping to be used for SSR', async () => { @@ -202,8 +206,102 @@ describe('ReactFlightTurbopackDOMNode', () => { // Create a delayed stream to simulate that the RSC stream might be // transported slower than the debug channel, which must not lead to a - // `controller.enqueueModel is not a function` error in the Flight client. - const readable = createDelayedStream(); + // `Connection closed` error in the Flight client. + const {delayedStream, resolveDelayedStream} = createDelayedStream(); + + rscStream.pipe(delayedStream); + + function ClientRoot({response}) { + return use(response); + } + + const serverConsumerManifest = { + moduleMap: { + [turbopackMap[ClientComponentOnTheClient.$$id].id]: { + '*': turbopackMap[ClientComponentOnTheServer.$$id], + }, + }, + moduleLoading: null, + }; + + const response = ReactServerDOMClient.createFromNodeStream( + delayedStream, + serverConsumerManifest, + {debugChannel: debugReadable}, + ); + + setTimeout(resolveDelayedStream); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + + expect(result).toContain( + 'Switched to client rendering because the server rendering errored:\n\nssr-throw', + ); + }); + + // @gate __DEV__ + it('can transport debug info through a slow 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), + ); + } + + // Create a delayed stream to simulate that the debug stream might be + // transported slower than the RSC stream, which must not lead to missing + // debug info. + const {delayedStream, resolveDelayedStream} = createDelayedStream(); + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App, null), + turbopackMap, + { + debugChannel: new Stream.Writable({ + write(chunk, encoding, callback) { + delayedStream.write(chunk, encoding); + callback(); + }, + final() { + delayedStream.end(); + }, + }), + }, + ), + ); + + const readable = new Stream.PassThrough(streamOptions); rscStream.pipe(readable); @@ -223,9 +321,11 @@ describe('ReactFlightTurbopackDOMNode', () => { const response = ReactServerDOMClient.createFromNodeStream( readable, serverConsumerManifest, - {debugChannel: debugReadable}, + {debugChannel: delayedStream}, ); + setTimeout(resolveDelayedStream); + let ownerStack; const ssrStream = await serverAct(() => diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js index 1ca44135a4b..dc4c99dabd0 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js @@ -10,9 +10,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type { - Response as FlightResponse, - FindSourceMapURLCallback, + DebugChannel, DebugChannelCallback, + FindSourceMapURLCallback, + Response as FlightResponse, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -71,6 +72,19 @@ function createDebugCallbackFromWritableStream( } function createResponseFromOptions(options: void | Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream( + options.debugChannel.writable, + ) + : null, + } + : undefined; + return createResponse( null, null, @@ -88,12 +102,7 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, - __DEV__ && - options && - options.debugChannel !== undefined && - options.debugChannel.writable !== undefined - ? createDebugCallbackFromWritableStream(options.debugChannel.writable) - : undefined, + debugChannel, ); } diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js index 4c7e99d2149..245761b2722 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js @@ -10,6 +10,7 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; import type { + DebugChannel, Response as FlightResponse, FindSourceMapURLCallback, } from 'react-client/src/ReactFlightClient'; @@ -83,6 +84,14 @@ export type Options = { }; function createResponseFromOptions(options: Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: null, + } + : undefined; + return createResponse( options.serverConsumerManifest.moduleMap, options.serverConsumerManifest.serverModuleMap, @@ -100,6 +109,7 @@ function createResponseFromOptions(options: Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + debugChannel, ); } diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js index 97e9be1bcdd..af8b7f41bc8 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js @@ -10,8 +10,9 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; import type { - Response, + DebugChannel, FindSourceMapURLCallback, + Response, } from 'react-client/src/ReactFlightClient'; import type { @@ -90,6 +91,14 @@ function createFromNodeStream( serverConsumerManifest: ServerConsumerManifest, options?: Options, ): Thenable { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: null, + } + : undefined; + const response: Response = createResponse( serverConsumerManifest.moduleMap, serverConsumerManifest.serverModuleMap, @@ -105,6 +114,7 @@ function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + debugChannel, ); if (__DEV__ && options && options.debugChannel) { 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 969bd493f3f..cd546f61359 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -174,6 +174,15 @@ describe('ReactFlightDOMBrowser', () => { }); } + function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) + ); + } + it('should resolve HTML using W3C streams', async () => { function Text({children}) { return {children}; @@ -2767,4 +2776,74 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('
Hi
'); }); + + it('can transport debug info through a dedicated debug channel', async () => { + let ownerStack; + + const ClientComponent = clientExports(() => { + ownerStack = React.captureOwnerStack ? React.captureOwnerStack() : null; + return

Hi

; + }); + + function App() { + return ReactServer.createElement( + ReactServer.Suspense, + null, + ReactServer.createElement(ClientComponent, null), + ); + } + + let debugReadableStreamController; + + const debugReadableStream = new ReadableStream({ + start(controller) { + debugReadableStreamController = controller; + }, + }); + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + ReactServer.createElement(App, null), + webpackMap, + { + debugChannel: { + writable: new WritableStream({ + write(chunk) { + debugReadableStreamController.enqueue(chunk); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ); + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + replayConsoleLogs: true, + debugChannel: { + readable: debugReadableStream, + // Explicitly not defining a writable side here. Its presence was + // previously used as a condition to wait for referenced debug chunks. + }, + }); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render(); + }); + + if (__DEV__) { + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + } + + expect(container.innerHTML).toBe('

Hi

'); + }); }); 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 bd93cc57565..98bc21576b0 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -2089,4 +2089,103 @@ describe('ReactFlightDOMEdge', () => { 'Switched to client rendering because the server rendering errored:\n\nssr-throw', ); }); + + // @gate __DEV__ + it('can transport debug info through a slow 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); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ), + ); + + 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: + // Create a delayed stream to simulate that the debug stream might be + // transported slower than the RSC stream, which must not lead to + // missing debug info. + createDelayedStream(debugReadableStream), + }, + }); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + 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 2ee9bfa961c..f069b23b293 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -152,16 +152,19 @@ describe('ReactFlightDOMNode', () => { } function createDelayedStream() { - return new Stream.Transform({ + let resolveDelayedStream; + const promise = new Promise(resolve => (resolveDelayedStream = resolve)); + const delayedStream = new Stream.Transform({ ...streamOptions, transform(chunk, encoding, callback) { - // Artificially delay between pushing chunks. - setTimeout(() => { + // Artificially delay pushing the chunk. + promise.then(() => { this.push(chunk); callback(); }); }, }); + return {delayedStream, resolveDelayedStream}; } it('should support web streams in node', async () => { @@ -963,8 +966,102 @@ describe('ReactFlightDOMNode', () => { // Create a delayed stream to simulate that the RSC stream might be // transported slower than the debug channel, which must not lead to a - // `controller.enqueueModel is not a function` error in the Flight client. - const readable = createDelayedStream(); + // `Connection closed` error in the Flight client. + const {delayedStream, resolveDelayedStream} = createDelayedStream(); + + rscStream.pipe(delayedStream); + + function ClientRoot({response}) { + return use(response); + } + + const serverConsumerManifest = { + moduleMap: { + [webpackMap[ClientComponentOnTheClient.$$id].id]: { + '*': webpackMap[ClientComponentOnTheServer.$$id], + }, + }, + moduleLoading: webpackModuleLoading, + }; + + const response = ReactServerDOMClient.createFromNodeStream( + delayedStream, + serverConsumerManifest, + {debugChannel: debugReadable}, + ); + + setTimeout(resolveDelayedStream); + + let ownerStack; + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream( + , + { + onError(err, errorInfo) { + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ), + ); + + const result = await readResult(ssrStream); + + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)'); + + expect(result).toContain( + 'Switched to client rendering because the server rendering errored:\n\nssr-throw', + ); + }); + + // @gate __DEV__ + it('can transport debug info through a slow 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), + ); + } + + // Create a delayed stream to simulate that the debug stream might be + // transported slower than the RSC stream, which must not lead to missing + // debug info. + const {delayedStream, resolveDelayedStream} = createDelayedStream(); + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App, null), + webpackMap, + { + debugChannel: new Stream.Writable({ + write(chunk, encoding, callback) { + delayedStream.write(chunk, encoding); + callback(); + }, + final() { + delayedStream.end(); + }, + }), + }, + ), + ); + + const readable = new Stream.PassThrough(streamOptions); rscStream.pipe(readable); @@ -984,9 +1081,11 @@ describe('ReactFlightDOMNode', () => { const response = ReactServerDOMClient.createFromNodeStream( readable, serverConsumerManifest, - {debugChannel: debugReadable}, + {debugChannel: delayedStream}, ); + setTimeout(resolveDelayedStream); + let ownerStack; const ssrStream = await serverAct(() => diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js index 1ca44135a4b..dc4c99dabd0 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js @@ -10,9 +10,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type { - Response as FlightResponse, - FindSourceMapURLCallback, + DebugChannel, DebugChannelCallback, + FindSourceMapURLCallback, + Response as FlightResponse, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -71,6 +72,19 @@ function createDebugCallbackFromWritableStream( } function createResponseFromOptions(options: void | Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream( + options.debugChannel.writable, + ) + : null, + } + : undefined; + return createResponse( null, null, @@ -88,12 +102,7 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, - __DEV__ && - options && - options.debugChannel !== undefined && - options.debugChannel.writable !== undefined - ? createDebugCallbackFromWritableStream(options.debugChannel.writable) - : undefined, + debugChannel, ); } diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js index 4c7e99d2149..7c9a707a546 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js @@ -10,8 +10,9 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; import type { - Response as FlightResponse, + DebugChannel, FindSourceMapURLCallback, + Response as FlightResponse, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -83,6 +84,14 @@ export type Options = { }; function createResponseFromOptions(options: Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: null, + } + : undefined; + return createResponse( options.serverConsumerManifest.moduleMap, options.serverConsumerManifest.serverModuleMap, @@ -100,6 +109,7 @@ function createResponseFromOptions(options: Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + debugChannel, ); } diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js index 97e9be1bcdd..af8b7f41bc8 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js @@ -10,8 +10,9 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; import type { - Response, + DebugChannel, FindSourceMapURLCallback, + Response, } from 'react-client/src/ReactFlightClient'; import type { @@ -90,6 +91,14 @@ function createFromNodeStream( serverConsumerManifest: ServerConsumerManifest, options?: Options, ): Thenable { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: null, + } + : undefined; + const response: Response = createResponse( serverConsumerManifest.moduleMap, serverConsumerManifest.serverModuleMap, @@ -105,6 +114,7 @@ function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + debugChannel, ); if (__DEV__ && options && options.debugChannel) {