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 0e22e3f294..61a67bce9d 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 bd0e0dfa14..d61f132310 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 206ba2faea..3500b3f41f 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 c118077a08..808b49d9d7 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 03c050f4cb..54c72968c2 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 42e71c2c8a..b513fd3fac 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 a62ce7b8e7..d9061d8e44 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 be3e7e476d..ec2d42201b 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 1fcf52fde4..59e8ea3947 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 1ca44135a4..dc4c99dabd 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 4c7e99d214..245761b272 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 97e9be1bcd..af8b7f41bc 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 969bd493f3..cd546f6135 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 bd93cc5756..98bc21576b 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 2ee9bfa961..f069b23b29 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 1ca44135a4..dc4c99dabd 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 4c7e99d214..7c9a707a54 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 97e9be1bcd..af8b7f41bc 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) {