diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 0e22e3f294fb5..61a67bce9d803 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 bd0e0dfa1436c..d61f132310547 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 206ba2faea9f1..3500b3f41f2b9 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 c118077a08662..808b49d9d75d0 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 03c050f4cba3b..54c72968c22fe 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 42e71c2c8a764..b513fd3faca33 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 a62ce7b8e74e0..d9061d8e44231 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 be3e7e476d91f..ec2d42201b97a 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 1fcf52fde4443..59e8ea3947094 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 1ca44135a4b8c..dc4c99dabd055 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 4c7e99d2149bc..245761b2722a6 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 97e9be1bcddf6..af8b7f41bc837 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 969bd493f3f47..cd546f61359dc 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 bd93cc575653e..98bc21576b08a 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 2ee9bfa961c2b..f069b23b293c0 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 1ca44135a4b8c..dc4c99dabd055 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 4c7e99d2149bc..7c9a707a546a6 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 97e9be1bcddf6..af8b7f41bc837 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) {