@samitouri / QOS-React / commits / 9c2e2b8475

[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.

Hendrik Liebau committed Aug 27, 2025 at 13:50 UTC 9c2e2b8475fb9d55fe47f55b007fba2d474e06f4
18 files changed +763 -105
packages/react-client/src/ReactFlightClient.js
+29 -19
@@ -341,6 +341,11 @@ export type FindSourceMapURLCallback = (
341
342 export type DebugChannelCallback = (message: string) => void;
343
344 +export type DebugChannel = {
345 + hasReadable: boolean,
346 + callback: DebugChannelCallback | null,
347 +};
348 +
349 type Response = {
350 _bundlerConfig: ServerConsumerModuleMap,
351 _serverReferenceConfig: null | ServerManifest,
@@ -362,7 +367,7 @@ type Response = {
367 _debugRootStack?: null | Error, // DEV-only
368 _debugRootTask?: null | ConsoleTask, // DEV-only
369 _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only
365 - _debugChannel?: void | DebugChannelCallback, // DEV-only
370 + _debugChannel?: void | DebugChannel, // DEV-only
371 _blockedConsole?: null | SomeChunk<ConsoleEntry>, // DEV-only
372 _replayConsole: boolean, // DEV-only
373 _rootEnvironmentName: string, // DEV-only, the requested environment name.
@@ -404,16 +409,16 @@ function getWeakResponse(response: Response): WeakResponse {
409 }
410 }
411
407 -function cleanupDebugChannel(debugChannel: DebugChannelCallback): void {
408 - // When a Response gets GC:ed because nobody is referring to any of the objects that lazily
409 - // loads from the Response anymore, then we can close the debug channel.
410 - debugChannel('');
412 +function closeDebugChannel(debugChannel: DebugChannel): void {
413 + if (debugChannel.callback) {
414 + debugChannel.callback('');
415 + }
416 }
417
418 // If FinalizationRegistry doesn't exist, we cannot use the debugChannel.
419 const debugChannelRegistry =
420 __DEV__ && typeof FinalizationRegistry === 'function'
416 - ? new FinalizationRegistry(cleanupDebugChannel)
421 + ? new FinalizationRegistry(closeDebugChannel)
422 : null;
423
424 function readChunk<T>(chunk: SomeChunk<T>): T {
@@ -1007,7 +1012,7 @@ export function reportGlobalError(
1012 if (debugChannel !== undefined) {
1013 // If we don't have any more ways of reading data, we don't have to send any
1014 // more neither. So we close the writable side.
1010 - debugChannel('');
1015 + closeDebugChannel(debugChannel);
1016 response._debugChannel = undefined;
1017 }
1018 }
@@ -1494,8 +1499,8 @@ function waitForReference<T>(
1499 ): T {
1500 if (
1501 __DEV__ &&
1497 - // TODO: This should check for the existence of the "readable" side, not the "writable".
1498 - response._debugChannel === undefined
1502 + (response._debugChannel === undefined ||
1503 + !response._debugChannel.hasReadable)
1504 ) {
1505 if (
1506 referencedChunk.status === PENDING &&
@@ -2262,15 +2267,16 @@ function parseModelString(
2267 case 'Y': {
2268 if (__DEV__) {
2269 if (value.length > 2) {
2265 - const debugChannel = response._debugChannel;
2266 - if (debugChannel) {
2270 + const debugChannelCallback =
2271 + response._debugChannel && response._debugChannel.callback;
2272 + if (debugChannelCallback) {
2273 if (value[2] === '@') {
2274 // This is a deferred Promise.
2275 const ref = value.slice(3); // We assume this doesn't have a path just id.
2276 const id = parseInt(ref, 16);
2277 if (!response._chunks.has(id)) {
2278 // We haven't seen this id before. Query the server to start sending it.
2273 - debugChannel('P:' + ref);
2279 + debugChannelCallback('P:' + ref);
2280 }
2281 // Start waiting. This now creates a pending chunk if it doesn't already exist.
2282 // This is the actual Promise we're waiting for.
@@ -2280,7 +2286,7 @@ function parseModelString(
2286 const id = parseInt(ref, 16);
2287 if (!response._chunks.has(id)) {
2288 // We haven't seen this id before. Query the server to start sending it.
2283 - debugChannel('Q:' + ref);
2289 + debugChannelCallback('Q:' + ref);
2290 }
2291 // Start waiting. This now creates a pending chunk if it doesn't already exist.
2292 const chunk = getChunk(response, id);
@@ -2358,7 +2364,7 @@ function ResponseInstance(
2364 findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only
2365 replayConsole: boolean, // DEV-only
2366 environmentName: void | string, // DEV-only
2361 - debugChannel: void | DebugChannelCallback, // DEV-only
2367 + debugChannel: void | DebugChannel, // DEV-only
2368 ) {
2369 const chunks: Map<number, SomeChunk<any>> = new Map();
2370 this._bundlerConfig = bundlerConfig;
@@ -2420,10 +2426,14 @@ function ResponseInstance(
2426 this._rootEnvironmentName = rootEnv;
2427 if (debugChannel) {
2428 if (debugChannelRegistry === null) {
2423 - // We can't safely clean things up later, so we immediately close the debug channel.
2424 - debugChannel('');
2429 + // We can't safely clean things up later, so we immediately close the
2430 + // debug channel.
2431 + closeDebugChannel(debugChannel);
2432 this._debugChannel = undefined;
2433 } else {
2434 + // When a Response gets GC:ed because nobody is referring to any of the
2435 + // objects that lazily load from the Response anymore, then we can close
2436 + // the debug channel.
2437 debugChannelRegistry.register(this, debugChannel);
2438 }
2439 }
@@ -2451,7 +2461,7 @@ export function createResponse(
2461 findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only
2462 replayConsole: boolean, // DEV-only
2463 environmentName: void | string, // DEV-only
2454 - debugChannel: void | DebugChannelCallback, // DEV-only
2464 + debugChannel: void | DebugChannel, // DEV-only
2465 ): WeakResponse {
2466 return getWeakResponse(
2467 // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
@@ -3545,8 +3555,8 @@ function resolveDebugModel(
3555 if (
3556 __DEV__ &&
3557 ((debugChunk: any): SomeChunk<any>).status === BLOCKED &&
3548 - // TODO: This should check for the existence of the "readable" side, not the "writable".
3549 - response._debugChannel === undefined
3558 + (response._debugChannel === undefined ||
3559 + !response._debugChannel.hasReadable)
3560 ) {
3561 if (json[0] === '"' && json[1] === '$') {
3562 const path = json.slice(2, json.length - 1).split(':');
packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js
+17 -8
@@ -10,9 +10,10 @@
10 import type {Thenable} from 'shared/ReactTypes.js';
11
12 import type {
13 - Response as FlightResponse,
14 - FindSourceMapURLCallback,
13 + DebugChannel,
14 DebugChannelCallback,
15 + FindSourceMapURLCallback,
16 + Response as FlightResponse,
17 } from 'react-client/src/ReactFlightClient';
18
19 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
@@ -72,6 +73,19 @@ function createDebugCallbackFromWritableStream(
73 }
74
75 function createResponseFromOptions(options: void | Options) {
76 + const debugChannel: void | DebugChannel =
77 + __DEV__ && options && options.debugChannel !== undefined
78 + ? {
79 + hasReadable: options.debugChannel.readable !== undefined,
80 + callback:
81 + options.debugChannel.writable !== undefined
82 + ? createDebugCallbackFromWritableStream(
83 + options.debugChannel.writable,
84 + )
85 + : null,
86 + }
87 + : undefined;
88 +
89 return createResponse(
90 options && options.moduleBaseURL ? options.moduleBaseURL : '',
91 null,
@@ -89,12 +103,7 @@ function createResponseFromOptions(options: void | Options) {
103 __DEV__ && options && options.environmentName
104 ? options.environmentName
105 : undefined,
92 - __DEV__ &&
93 - options &&
94 - options.debugChannel !== undefined &&
95 - options.debugChannel.writable !== undefined
96 - ? createDebugCallbackFromWritableStream(options.debugChannel.writable)
97 - : undefined,
106 + debugChannel,
107 );
108 }
109
packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js
+11 -1
@@ -10,8 +10,9 @@
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 import type {
13 - Response,
13 + DebugChannel,
14 FindSourceMapURLCallback,
15 + Response,
16 } from 'react-client/src/ReactFlightClient';
17
18 import type {Readable} from 'stream';
@@ -88,6 +89,14 @@ function createFromNodeStream<T>(
89 moduleBaseURL: string,
90 options?: Options,
91 ): Thenable<T> {
92 + const debugChannel: void | DebugChannel =
93 + __DEV__ && options && options.debugChannel !== undefined
94 + ? {
95 + hasReadable: options.debugChannel.readable !== undefined,
96 + callback: null,
97 + }
98 + : undefined;
99 +
100 const response: Response = createResponse(
101 moduleRootPath,
102 null,
@@ -103,6 +112,7 @@ function createFromNodeStream<T>(
112 __DEV__ && options && options.environmentName
113 ? options.environmentName
114 : undefined,
115 + debugChannel,
116 );
117
118 if (__DEV__ && options && options.debugChannel) {
packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js
+37 -45
@@ -9,8 +9,9 @@
9
10 import type {Thenable} from 'shared/ReactTypes.js';
11 import type {
12 - Response as FlightResponse,
12 + DebugChannel,
13 DebugChannelCallback,
14 + Response as FlightResponse,
15 } from 'react-client/src/ReactFlightClient';
16 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
17 import type {ServerReferenceId} from '../client/ReactFlightClientConfigBundlerParcel';
@@ -99,6 +100,39 @@ function createDebugCallbackFromWritableStream(
100 };
101 }
102
103 +function createResponseFromOptions(options: void | Options) {
104 + const debugChannel: void | DebugChannel =
105 + __DEV__ && options && options.debugChannel !== undefined
106 + ? {
107 + hasReadable: options.debugChannel.readable !== undefined,
108 + callback:
109 + options.debugChannel.writable !== undefined
110 + ? createDebugCallbackFromWritableStream(
111 + options.debugChannel.writable,
112 + )
113 + : null,
114 + }
115 + : undefined;
116 +
117 + return createResponse(
118 + null, // bundlerConfig
119 + null, // serverReferenceConfig
120 + null, // moduleLoading
121 + callCurrentServerCallback,
122 + undefined, // encodeFormAction
123 + undefined, // nonce
124 + options && options.temporaryReferences
125 + ? options.temporaryReferences
126 + : undefined,
127 + __DEV__ ? findSourceMapURL : undefined,
128 + __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true
129 + __DEV__ && options && options.environmentName
130 + ? options.environmentName
131 + : undefined,
132 + debugChannel,
133 + );
134 +}
135 +
136 function startReadingFromUniversalStream(
137 response: FlightResponse,
138 stream: ReadableStream,
@@ -176,28 +210,7 @@ export function createFromReadableStream<T>(
210 stream: ReadableStream,
211 options?: Options,
212 ): Thenable<T> {
179 - const response: FlightResponse = createResponse(
180 - null, // bundlerConfig
181 - null, // serverReferenceConfig
182 - null, // moduleLoading
183 - callCurrentServerCallback,
184 - undefined, // encodeFormAction
185 - undefined, // nonce
186 - options && options.temporaryReferences
187 - ? options.temporaryReferences
188 - : undefined,
189 - __DEV__ ? findSourceMapURL : undefined,
190 - __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true
191 - __DEV__ && options && options.environmentName
192 - ? options.environmentName
193 - : undefined,
194 - __DEV__ &&
195 - options &&
196 - options.debugChannel !== undefined &&
197 - options.debugChannel.writable !== undefined
198 - ? createDebugCallbackFromWritableStream(options.debugChannel.writable)
199 - : undefined,
200 - );
213 + const response: FlightResponse = createResponseFromOptions(options);
214 if (
215 __DEV__ &&
216 options &&
@@ -226,28 +239,7 @@ export function createFromFetch<T>(
239 promiseForResponse: Promise<Response>,
240 options?: Options,
241 ): Thenable<T> {
229 - const response: FlightResponse = createResponse(
230 - null, // bundlerConfig
231 - null, // serverReferenceConfig
232 - null, // moduleLoading
233 - callCurrentServerCallback,
234 - undefined, // encodeFormAction
235 - undefined, // nonce
236 - options && options.temporaryReferences
237 - ? options.temporaryReferences
238 - : undefined,
239 - __DEV__ ? findSourceMapURL : undefined,
240 - __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true
241 - __DEV__ && options && options.environmentName
242 - ? options.environmentName
243 - : undefined,
244 - __DEV__ &&
245 - options &&
246 - options.debugChannel !== undefined &&
247 - options.debugChannel.writable !== undefined
248 - ? createDebugCallbackFromWritableStream(options.debugChannel.writable)
249 - : undefined,
250 - );
242 + const response: FlightResponse = createResponseFromOptions(options);
243 promiseForResponse.then(
244 function (r) {
245 if (
packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js
+13 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + DebugChannel,
14 + Response as FlightResponse,
15 +} from 'react-client/src/ReactFlightClient';
16 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
17
18 import {
@@ -81,6 +84,14 @@ export type Options = {
84 };
85
86 function createResponseFromOptions(options?: Options) {
87 + const debugChannel: void | DebugChannel =
88 + __DEV__ && options && options.debugChannel !== undefined
89 + ? {
90 + hasReadable: options.debugChannel.readable !== undefined,
91 + callback: null,
92 + }
93 + : undefined;
94 +
95 return createResponse(
96 null, // bundlerConfig
97 null, // serverReferenceConfig
@@ -96,6 +107,7 @@ function createResponseFromOptions(options?: Options) {
107 __DEV__ && options && options.environmentName
108 ? options.environmentName
109 : undefined,
110 + debugChannel,
111 );
112 }
113
packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js
+10 -1
@@ -8,7 +8,7 @@
8 */
9
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11 -import type {Response} from 'react-client/src/ReactFlightClient';
11 +import type {DebugChannel, Response} from 'react-client/src/ReactFlightClient';
12 import type {Readable} from 'stream';
13
14 import {
@@ -82,6 +82,14 @@ export function createFromNodeStream<T>(
82 stream: Readable,
83 options?: Options,
84 ): Thenable<T> {
85 + const debugChannel: void | DebugChannel =
86 + __DEV__ && options && options.debugChannel !== undefined
87 + ? {
88 + hasReadable: options.debugChannel.readable !== undefined,
89 + callback: null,
90 + }
91 + : undefined;
92 +
93 const response: Response = createResponse(
94 null, // bundlerConfig
95 null, // serverReferenceConfig
@@ -95,6 +103,7 @@ export function createFromNodeStream<T>(
103 __DEV__ && options && options.environmentName
104 ? options.environmentName
105 : undefined,
106 + debugChannel,
107 );
108
109 if (__DEV__ && options && options.debugChannel) {
packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js
+84
@@ -19,10 +19,12 @@ global.WritableStream =
19 global.TextEncoder = require('util').TextEncoder;
20 global.TextDecoder = require('util').TextDecoder;
21
22 +let clientExports;
23 let React;
24 let ReactDOMClient;
25 let ReactServerDOMServer;
26 let ReactServerDOMClient;
27 +let ReactServer;
28 let ReactServerScheduler;
29 let act;
30 let serverAct;
@@ -39,10 +41,13 @@ describe('ReactFlightTurbopackDOMBrowser', () => {
41
42 // Simulate the condition resolution
43 jest.mock('react', () => require('react/react.react-server'));
44 + ReactServer = require('react');
45 +
46 jest.mock('react-server-dom-turbopack/server', () =>
47 require('react-server-dom-turbopack/server.browser'),
48 );
49 const TurbopackMock = require('./utils/TurbopackMock');
50 + clientExports = TurbopackMock.clientExports;
51 turbopackMap = TurbopackMock.turbopackMap;
52
53 ReactServerDOMServer = require('react-server-dom-turbopack/server.browser');
@@ -77,6 +82,15 @@ describe('ReactFlightTurbopackDOMBrowser', () => {
82 });
83 }
84
85 + function normalizeCodeLocInfo(str) {
86 + return (
87 + str &&
88 + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) {
89 + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : '');
90 + })
91 + );
92 + }
93 +
94 it('should resolve HTML using W3C streams', async () => {
95 function Text({children}) {
96 return <span>{children}</span>;
@@ -163,4 +177,74 @@ describe('ReactFlightTurbopackDOMBrowser', () => {
177
178 expect(container.innerHTML).toBe('<div>Hi</div>');
179 });
180 +
181 + it('can transport debug info through a dedicated debug channel', async () => {
182 + let ownerStack;
183 +
184 + const ClientComponent = clientExports(() => {
185 + ownerStack = React.captureOwnerStack ? React.captureOwnerStack() : null;
186 + return <p>Hi</p>;
187 + });
188 +
189 + function App() {
190 + return ReactServer.createElement(
191 + ReactServer.Suspense,
192 + null,
193 + ReactServer.createElement(ClientComponent, null),
194 + );
195 + }
196 +
197 + let debugReadableStreamController;
198 +
199 + const debugReadableStream = new ReadableStream({
200 + start(controller) {
201 + debugReadableStreamController = controller;
202 + },
203 + });
204 +
205 + const rscStream = await serverAct(() =>
206 + ReactServerDOMServer.renderToReadableStream(
207 + ReactServer.createElement(App, null),
208 + turbopackMap,
209 + {
210 + debugChannel: {
211 + writable: new WritableStream({
212 + write(chunk) {
213 + debugReadableStreamController.enqueue(chunk);
214 + },
215 + close() {
216 + debugReadableStreamController.close();
217 + },
218 + }),
219 + },
220 + },
221 + ),
222 + );
223 +
224 + function ClientRoot({response}) {
225 + return use(response);
226 + }
227 +
228 + const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
229 + replayConsoleLogs: true,
230 + debugChannel: {
231 + readable: debugReadableStream,
232 + // Explicitly not defining a writable side here. Its presence was
233 + // previously used as a condition to wait for referenced debug chunks.
234 + },
235 + });
236 +
237 + const container = document.createElement('div');
238 + const root = ReactDOMClient.createRoot(container);
239 +
240 + await act(() => {
241 + root.render(<ClientRoot response={response} />);
242 + });
243 +
244 + if (__DEV__) {
245 + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
246 + }
247 +
248 + expect(container.innerHTML).toBe('<p>Hi</p>');
249 + });
250 });
packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js
+97
@@ -241,4 +241,101 @@ describe('ReactFlightTurbopackDOMEdge', () => {
241 'Switched to client rendering because the server rendering errored:\n\nssr-throw',
242 );
243 });
244 +
245 + // @gate __DEV__
246 + it('can transport debug info through a slow debug channel', async () => {
247 + function Thrower() {
248 + throw new Error('ssr-throw');
249 + }
250 +
251 + const ClientComponentOnTheClient = clientExports(
252 + Thrower,
253 + 123,
254 + 'path/to/chunk.js',
255 + );
256 +
257 + const ClientComponentOnTheServer = clientExports(Thrower);
258 +
259 + function App() {
260 + return ReactServer.createElement(
261 + ReactServer.Suspense,
262 + null,
263 + ReactServer.createElement(ClientComponentOnTheClient, null),
264 + );
265 + }
266 +
267 + let debugReadableStreamController;
268 +
269 + const debugReadableStream = new ReadableStream({
270 + start(controller) {
271 + debugReadableStreamController = controller;
272 + },
273 + });
274 +
275 + const rscStream = await serverAct(() =>
276 + ReactServerDOMServer.renderToReadableStream(
277 + ReactServer.createElement(App, null),
278 + turbopackMap,
279 + {
280 + debugChannel: {
281 + writable: new WritableStream({
282 + write(chunk) {
283 + debugReadableStreamController.enqueue(chunk);
284 + },
285 + close() {
286 + debugReadableStreamController.close();
287 + },
288 + }),
289 + },
290 + },
291 + ),
292 + );
293 +
294 + function ClientRoot({response}) {
295 + return use(response);
296 + }
297 +
298 + const serverConsumerManifest = {
299 + moduleMap: {
300 + [turbopackMap[ClientComponentOnTheClient.$$id].id]: {
301 + '*': turbopackMap[ClientComponentOnTheServer.$$id],
302 + },
303 + },
304 + moduleLoading: null,
305 + };
306 +
307 + const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
308 + serverConsumerManifest,
309 + debugChannel: {
310 + readable:
311 + // Create a delayed stream to simulate that the debug stream might
312 + // be transported slower than the RSC stream, which must not lead to
313 + // missing debug info.
314 + createDelayedStream(debugReadableStream),
315 + },
316 + });
317 +
318 + let ownerStack;
319 +
320 + const ssrStream = await serverAct(() =>
321 + ReactDOMServer.renderToReadableStream(
322 + <ClientRoot response={response} />,
323 + {
324 + onError(err, errorInfo) {
325 + ownerStack = React.captureOwnerStack
326 + ? React.captureOwnerStack()
327 + : null;
328 + },
329 + },
330 + ),
331 + );
332 +
333 + const result = await readResult(ssrStream);
334 +
335 + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
336 +
337 + expect(result).toContain(
338 + 'Switched to client rendering because the server rendering errored:\n\nssr-throw',
339 + );
340 + });
341 });
packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js
+105 -5
@@ -91,15 +91,19 @@ describe('ReactFlightTurbopackDOMNode', () => {
91 }
92
93 function createDelayedStream() {
94 - return new Stream.Transform({
94 + let resolveDelayedStream;
95 + const promise = new Promise(resolve => (resolveDelayedStream = resolve));
96 + const delayedStream = new Stream.Transform({
97 ...streamOptions,
98 transform(chunk, encoding, callback) {
97 - setTimeout(() => {
99 + // Artificially delay pushing the chunk.
100 + promise.then(() => {
101 this.push(chunk);
102 callback();
103 });
104 },
105 });
106 + return {delayedStream, resolveDelayedStream};
107 }
108
109 it('should allow an alternative module mapping to be used for SSR', async () => {
@@ -202,8 +206,102 @@ describe('ReactFlightTurbopackDOMNode', () => {
206
207 // Create a delayed stream to simulate that the RSC stream might be
208 // transported slower than the debug channel, which must not lead to a
205 - // `controller.enqueueModel is not a function` error in the Flight client.
206 - const readable = createDelayedStream();
209 + // `Connection closed` error in the Flight client.
210 + const {delayedStream, resolveDelayedStream} = createDelayedStream();
211 +
212 + rscStream.pipe(delayedStream);
213 +
214 + function ClientRoot({response}) {
215 + return use(response);
216 + }
217 +
218 + const serverConsumerManifest = {
219 + moduleMap: {
220 + [turbopackMap[ClientComponentOnTheClient.$$id].id]: {
221 + '*': turbopackMap[ClientComponentOnTheServer.$$id],
222 + },
223 + },
224 + moduleLoading: null,
225 + };
226 +
227 + const response = ReactServerDOMClient.createFromNodeStream(
228 + delayedStream,
229 + serverConsumerManifest,
230 + {debugChannel: debugReadable},
231 + );
232 +
233 + setTimeout(resolveDelayedStream);
234 +
235 + let ownerStack;
236 +
237 + const ssrStream = await serverAct(() =>
238 + ReactDOMServer.renderToPipeableStream(
239 + <ClientRoot response={response} />,
240 + {
241 + onError(err, errorInfo) {
242 + ownerStack = React.captureOwnerStack
243 + ? React.captureOwnerStack()
244 + : null;
245 + },
246 + },
247 + ),
248 + );
249 +
250 + const result = await readResult(ssrStream);
251 +
252 + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
253 +
254 + expect(result).toContain(
255 + 'Switched to client rendering because the server rendering errored:\n\nssr-throw',
256 + );
257 + });
258 +
259 + // @gate __DEV__
260 + it('can transport debug info through a slow debug channel', async () => {
261 + function Thrower() {
262 + throw new Error('ssr-throw');
263 + }
264 +
265 + const ClientComponentOnTheClient = clientExports(
266 + Thrower,
267 + 123,
268 + 'path/to/chunk.js',
269 + );
270 +
271 + const ClientComponentOnTheServer = clientExports(Thrower);
272 +
273 + function App() {
274 + return ReactServer.createElement(
275 + ReactServer.Suspense,
276 + null,
277 + ReactServer.createElement(ClientComponentOnTheClient, null),
278 + );
279 + }
280 +
281 + // Create a delayed stream to simulate that the debug stream might be
282 + // transported slower than the RSC stream, which must not lead to missing
283 + // debug info.
284 + const {delayedStream, resolveDelayedStream} = createDelayedStream();
285 +
286 + const rscStream = await serverAct(() =>
287 + ReactServerDOMServer.renderToPipeableStream(
288 + ReactServer.createElement(App, null),
289 + turbopackMap,
290 + {
291 + debugChannel: new Stream.Writable({
292 + write(chunk, encoding, callback) {
293 + delayedStream.write(chunk, encoding);
294 + callback();
295 + },
296 + final() {
297 + delayedStream.end();
298 + },
299 + }),
300 + },
301 + ),
302 + );
303 +
304 + const readable = new Stream.PassThrough(streamOptions);
305
306 rscStream.pipe(readable);
307
@@ -223,9 +321,11 @@ describe('ReactFlightTurbopackDOMNode', () => {
321 const response = ReactServerDOMClient.createFromNodeStream(
322 readable,
323 serverConsumerManifest,
226 - {debugChannel: debugReadable},
324 + {debugChannel: delayedStream},
325 );
326
327 + setTimeout(resolveDelayedStream);
328 +
329 let ownerStack;
330
331 const ssrStream = await serverAct(() =>
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js
+17 -8
@@ -10,9 +10,10 @@
10 import type {Thenable} from 'shared/ReactTypes.js';
11
12 import type {
13 - Response as FlightResponse,
14 - FindSourceMapURLCallback,
13 + DebugChannel,
14 DebugChannelCallback,
15 + FindSourceMapURLCallback,
16 + Response as FlightResponse,
17 } from 'react-client/src/ReactFlightClient';
18
19 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
@@ -71,6 +72,19 @@ function createDebugCallbackFromWritableStream(
72 }
73
74 function createResponseFromOptions(options: void | Options) {
75 + const debugChannel: void | DebugChannel =
76 + __DEV__ && options && options.debugChannel !== undefined
77 + ? {
78 + hasReadable: options.debugChannel.readable !== undefined,
79 + callback:
80 + options.debugChannel.writable !== undefined
81 + ? createDebugCallbackFromWritableStream(
82 + options.debugChannel.writable,
83 + )
84 + : null,
85 + }
86 + : undefined;
87 +
88 return createResponse(
89 null,
90 null,
@@ -88,12 +102,7 @@ function createResponseFromOptions(options: void | Options) {
102 __DEV__ && options && options.environmentName
103 ? options.environmentName
104 : undefined,
91 - __DEV__ &&
92 - options &&
93 - options.debugChannel !== undefined &&
94 - options.debugChannel.writable !== undefined
95 - ? createDebugCallbackFromWritableStream(options.debugChannel.writable)
96 - : undefined,
105 + debugChannel,
106 );
107 }
108
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js
+10
@@ -10,6 +10,7 @@
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 import type {
13 + DebugChannel,
14 Response as FlightResponse,
15 FindSourceMapURLCallback,
16 } from 'react-client/src/ReactFlightClient';
@@ -83,6 +84,14 @@ export type Options = {
84 };
85
86 function createResponseFromOptions(options: Options) {
87 + const debugChannel: void | DebugChannel =
88 + __DEV__ && options && options.debugChannel !== undefined
89 + ? {
90 + hasReadable: options.debugChannel.readable !== undefined,
91 + callback: null,
92 + }
93 + : undefined;
94 +
95 return createResponse(
96 options.serverConsumerManifest.moduleMap,
97 options.serverConsumerManifest.serverModuleMap,
@@ -100,6 +109,7 @@ function createResponseFromOptions(options: Options) {
109 __DEV__ && options && options.environmentName
110 ? options.environmentName
111 : undefined,
112 + debugChannel,
113 );
114 }
115
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js
+11 -1
@@ -10,8 +10,9 @@
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 import type {
13 - Response,
13 + DebugChannel,
14 FindSourceMapURLCallback,
15 + Response,
16 } from 'react-client/src/ReactFlightClient';
17
18 import type {
@@ -90,6 +91,14 @@ function createFromNodeStream<T>(
91 serverConsumerManifest: ServerConsumerManifest,
92 options?: Options,
93 ): Thenable<T> {
94 + const debugChannel: void | DebugChannel =
95 + __DEV__ && options && options.debugChannel !== undefined
96 + ? {
97 + hasReadable: options.debugChannel.readable !== undefined,
98 + callback: null,
99 + }
100 + : undefined;
101 +
102 const response: Response = createResponse(
103 serverConsumerManifest.moduleMap,
104 serverConsumerManifest.serverModuleMap,
@@ -105,6 +114,7 @@ function createFromNodeStream<T>(
114 __DEV__ && options && options.environmentName
115 ? options.environmentName
116 : undefined,
117 + debugChannel,
118 );
119
120 if (__DEV__ && options && options.debugChannel) {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+79
@@ -174,6 +174,15 @@ describe('ReactFlightDOMBrowser', () => {
174 });
175 }
176
177 + function normalizeCodeLocInfo(str) {
178 + return (
179 + str &&
180 + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) {
181 + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : '');
182 + })
183 + );
184 + }
185 +
186 it('should resolve HTML using W3C streams', async () => {
187 function Text({children}) {
188 return <span>{children}</span>;
@@ -2767,4 +2776,74 @@ describe('ReactFlightDOMBrowser', () => {
2776
2777 expect(container.innerHTML).toBe('<div>Hi</div>');
2778 });
2779 +
2780 + it('can transport debug info through a dedicated debug channel', async () => {
2781 + let ownerStack;
2782 +
2783 + const ClientComponent = clientExports(() => {
2784 + ownerStack = React.captureOwnerStack ? React.captureOwnerStack() : null;
2785 + return <p>Hi</p>;
2786 + });
2787 +
2788 + function App() {
2789 + return ReactServer.createElement(
2790 + ReactServer.Suspense,
2791 + null,
2792 + ReactServer.createElement(ClientComponent, null),
2793 + );
2794 + }
2795 +
2796 + let debugReadableStreamController;
2797 +
2798 + const debugReadableStream = new ReadableStream({
2799 + start(controller) {
2800 + debugReadableStreamController = controller;
2801 + },
2802 + });
2803 +
2804 + const rscStream = await serverAct(() =>
2805 + ReactServerDOMServer.renderToReadableStream(
2806 + ReactServer.createElement(App, null),
2807 + webpackMap,
2808 + {
2809 + debugChannel: {
2810 + writable: new WritableStream({
2811 + write(chunk) {
2812 + debugReadableStreamController.enqueue(chunk);
2813 + },
2814 + close() {
2815 + debugReadableStreamController.close();
2816 + },
2817 + }),
2818 + },
2819 + },
2820 + ),
2821 + );
2822 +
2823 + function ClientRoot({response}) {
2824 + return use(response);
2825 + }
2826 +
2827 + const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
2828 + replayConsoleLogs: true,
2829 + debugChannel: {
2830 + readable: debugReadableStream,
2831 + // Explicitly not defining a writable side here. Its presence was
2832 + // previously used as a condition to wait for referenced debug chunks.
2833 + },
2834 + });
2835 +
2836 + const container = document.createElement('div');
2837 + const root = ReactDOMClient.createRoot(container);
2838 +
2839 + await act(() => {
2840 + root.render(<ClientRoot response={response} />);
2841 + });
2842 +
2843 + if (__DEV__) {
2844 + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
2845 + }
2846 +
2847 + expect(container.innerHTML).toBe('<p>Hi</p>');
2848 + });
2849 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+99
@@ -2089,4 +2089,103 @@ describe('ReactFlightDOMEdge', () => {
2089 'Switched to client rendering because the server rendering errored:\n\nssr-throw',
2090 );
2091 });
2092 +
2093 + // @gate __DEV__
2094 + it('can transport debug info through a slow debug channel', async () => {
2095 + function Thrower() {
2096 + throw new Error('ssr-throw');
2097 + }
2098 +
2099 + const ClientComponentOnTheClient = clientExports(
2100 + Thrower,
2101 + 123,
2102 + 'path/to/chunk.js',
2103 + );
2104 +
2105 + const ClientComponentOnTheServer = clientExports(Thrower);
2106 +
2107 + function App() {
2108 + return ReactServer.createElement(
2109 + ReactServer.Suspense,
2110 + null,
2111 + ReactServer.createElement(ClientComponentOnTheClient, null),
2112 + );
2113 + }
2114 +
2115 + let debugReadableStreamController;
2116 +
2117 + const debugReadableStream = new ReadableStream({
2118 + start(controller) {
2119 + debugReadableStreamController = controller;
2120 + },
2121 + });
2122 +
2123 + const rscStream = await serverAct(() =>
2124 + passThrough(
2125 + ReactServerDOMServer.renderToReadableStream(
2126 + ReactServer.createElement(App, null),
2127 + webpackMap,
2128 + {
2129 + debugChannel: {
2130 + writable: new WritableStream({
2131 + write(chunk) {
2132 + debugReadableStreamController.enqueue(chunk);
2133 + },
2134 + close() {
2135 + debugReadableStreamController.close();
2136 + },
2137 + }),
2138 + },
2139 + },
2140 + ),
2141 + ),
2142 + );
2143 +
2144 + function ClientRoot({response}) {
2145 + return use(response);
2146 + }
2147 +
2148 + const serverConsumerManifest = {
2149 + moduleMap: {
2150 + [webpackMap[ClientComponentOnTheClient.$$id].id]: {
2151 + '*': webpackMap[ClientComponentOnTheServer.$$id],
2152 + },
2153 + },
2154 + moduleLoading: webpackModuleLoading,
2155 + };
2156 +
2157 + const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
2158 + serverConsumerManifest,
2159 + debugChannel: {
2160 + readable:
2161 + // Create a delayed stream to simulate that the debug stream might be
2162 + // transported slower than the RSC stream, which must not lead to
2163 + // missing debug info.
2164 + createDelayedStream(debugReadableStream),
2165 + },
2166 + });
2167 +
2168 + let ownerStack;
2169 +
2170 + const ssrStream = await serverAct(() =>
2171 + ReactDOMServer.renderToReadableStream(
2172 + <ClientRoot response={response} />,
2173 + {
2174 + onError(err, errorInfo) {
2175 + ownerStack = React.captureOwnerStack
2176 + ? React.captureOwnerStack()
2177 + : null;
2178 + },
2179 + },
2180 + ),
2181 + );
2182 +
2183 + const result = await readResult(ssrStream);
2184 +
2185 + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
2186 +
2187 + expect(result).toContain(
2188 + 'Switched to client rendering because the server rendering errored:\n\nssr-throw',
2189 + );
2190 + });
2191 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+105 -6
@@ -152,16 +152,19 @@ describe('ReactFlightDOMNode', () => {
152 }
153
154 function createDelayedStream() {
155 - return new Stream.Transform({
155 + let resolveDelayedStream;
156 + const promise = new Promise(resolve => (resolveDelayedStream = resolve));
157 + const delayedStream = new Stream.Transform({
158 ...streamOptions,
159 transform(chunk, encoding, callback) {
158 - // Artificially delay between pushing chunks.
159 - setTimeout(() => {
160 + // Artificially delay pushing the chunk.
161 + promise.then(() => {
162 this.push(chunk);
163 callback();
164 });
165 },
166 });
167 + return {delayedStream, resolveDelayedStream};
168 }
169
170 it('should support web streams in node', async () => {
@@ -963,8 +966,102 @@ describe('ReactFlightDOMNode', () => {
966
967 // Create a delayed stream to simulate that the RSC stream might be
968 // transported slower than the debug channel, which must not lead to a
966 - // `controller.enqueueModel is not a function` error in the Flight client.
967 - const readable = createDelayedStream();
969 + // `Connection closed` error in the Flight client.
970 + const {delayedStream, resolveDelayedStream} = createDelayedStream();
971 +
972 + rscStream.pipe(delayedStream);
973 +
974 + function ClientRoot({response}) {
975 + return use(response);
976 + }
977 +
978 + const serverConsumerManifest = {
979 + moduleMap: {
980 + [webpackMap[ClientComponentOnTheClient.$$id].id]: {
981 + '*': webpackMap[ClientComponentOnTheServer.$$id],
982 + },
983 + },
984 + moduleLoading: webpackModuleLoading,
985 + };
986 +
987 + const response = ReactServerDOMClient.createFromNodeStream(
988 + delayedStream,
989 + serverConsumerManifest,
990 + {debugChannel: debugReadable},
991 + );
992 +
993 + setTimeout(resolveDelayedStream);
994 +
995 + let ownerStack;
996 +
997 + const ssrStream = await serverAct(() =>
998 + ReactDOMServer.renderToPipeableStream(
999 + <ClientRoot response={response} />,
1000 + {
1001 + onError(err, errorInfo) {
1002 + ownerStack = React.captureOwnerStack
1003 + ? React.captureOwnerStack()
1004 + : null;
1005 + },
1006 + },
1007 + ),
1008 + );
1009 +
1010 + const result = await readResult(ssrStream);
1011 +
1012 + expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
1013 +
1014 + expect(result).toContain(
1015 + 'Switched to client rendering because the server rendering errored:\n\nssr-throw',
1016 + );
1017 + });
1018 +
1019 + // @gate __DEV__
1020 + it('can transport debug info through a slow debug channel', async () => {
1021 + function Thrower() {
1022 + throw new Error('ssr-throw');
1023 + }
1024 +
1025 + const ClientComponentOnTheClient = clientExports(
1026 + Thrower,
1027 + 123,
1028 + 'path/to/chunk.js',
1029 + );
1030 +
1031 + const ClientComponentOnTheServer = clientExports(Thrower);
1032 +
1033 + function App() {
1034 + return ReactServer.createElement(
1035 + ReactServer.Suspense,
1036 + null,
1037 + ReactServer.createElement(ClientComponentOnTheClient, null),
1038 + );
1039 + }
1040 +
1041 + // Create a delayed stream to simulate that the debug stream might be
1042 + // transported slower than the RSC stream, which must not lead to missing
1043 + // debug info.
1044 + const {delayedStream, resolveDelayedStream} = createDelayedStream();
1045 +
1046 + const rscStream = await serverAct(() =>
1047 + ReactServerDOMServer.renderToPipeableStream(
1048 + ReactServer.createElement(App, null),
1049 + webpackMap,
1050 + {
1051 + debugChannel: new Stream.Writable({
1052 + write(chunk, encoding, callback) {
1053 + delayedStream.write(chunk, encoding);
1054 + callback();
1055 + },
1056 + final() {
1057 + delayedStream.end();
1058 + },
1059 + }),
1060 + },
1061 + ),
1062 + );
1063 +
1064 + const readable = new Stream.PassThrough(streamOptions);
1065
1066 rscStream.pipe(readable);
1067
@@ -984,9 +1081,11 @@ describe('ReactFlightDOMNode', () => {
1081 const response = ReactServerDOMClient.createFromNodeStream(
1082 readable,
1083 serverConsumerManifest,
987 - {debugChannel: debugReadable},
1084 + {debugChannel: delayedStream},
1085 );
1086
1087 + setTimeout(resolveDelayedStream);
1088 +
1089 let ownerStack;
1090
1091 const ssrStream = await serverAct(() =>
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js
+17 -8
@@ -10,9 +10,10 @@
10 import type {Thenable} from 'shared/ReactTypes.js';
11
12 import type {
13 - Response as FlightResponse,
14 - FindSourceMapURLCallback,
13 + DebugChannel,
14 DebugChannelCallback,
15 + FindSourceMapURLCallback,
16 + Response as FlightResponse,
17 } from 'react-client/src/ReactFlightClient';
18
19 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
@@ -71,6 +72,19 @@ function createDebugCallbackFromWritableStream(
72 }
73
74 function createResponseFromOptions(options: void | Options) {
75 + const debugChannel: void | DebugChannel =
76 + __DEV__ && options && options.debugChannel !== undefined
77 + ? {
78 + hasReadable: options.debugChannel.readable !== undefined,
79 + callback:
80 + options.debugChannel.writable !== undefined
81 + ? createDebugCallbackFromWritableStream(
82 + options.debugChannel.writable,
83 + )
84 + : null,
85 + }
86 + : undefined;
87 +
88 return createResponse(
89 null,
90 null,
@@ -88,12 +102,7 @@ function createResponseFromOptions(options: void | Options) {
102 __DEV__ && options && options.environmentName
103 ? options.environmentName
104 : undefined,
91 - __DEV__ &&
92 - options &&
93 - options.debugChannel !== undefined &&
94 - options.debugChannel.writable !== undefined
95 - ? createDebugCallbackFromWritableStream(options.debugChannel.writable)
96 - : undefined,
105 + debugChannel,
106 );
107 }
108
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js
+11 -1
@@ -10,8 +10,9 @@
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 import type {
13 - Response as FlightResponse,
13 + DebugChannel,
14 FindSourceMapURLCallback,
15 + Response as FlightResponse,
16 } from 'react-client/src/ReactFlightClient';
17
18 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
@@ -83,6 +84,14 @@ export type Options = {
84 };
85
86 function createResponseFromOptions(options: Options) {
87 + const debugChannel: void | DebugChannel =
88 + __DEV__ && options && options.debugChannel !== undefined
89 + ? {
90 + hasReadable: options.debugChannel.readable !== undefined,
91 + callback: null,
92 + }
93 + : undefined;
94 +
95 return createResponse(
96 options.serverConsumerManifest.moduleMap,
97 options.serverConsumerManifest.serverModuleMap,
@@ -100,6 +109,7 @@ function createResponseFromOptions(options: Options) {
109 __DEV__ && options && options.environmentName
110 ? options.environmentName
111 : undefined,
112 + debugChannel,
113 );
114 }
115
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js
+11 -1
@@ -10,8 +10,9 @@
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 import type {
13 - Response,
13 + DebugChannel,
14 FindSourceMapURLCallback,
15 + Response,
16 } from 'react-client/src/ReactFlightClient';
17
18 import type {
@@ -90,6 +91,14 @@ function createFromNodeStream<T>(
91 serverConsumerManifest: ServerConsumerManifest,
92 options?: Options,
93 ): Thenable<T> {
94 + const debugChannel: void | DebugChannel =
95 + __DEV__ && options && options.debugChannel !== undefined
96 + ? {
97 + hasReadable: options.debugChannel.readable !== undefined,
98 + callback: null,
99 + }
100 + : undefined;
101 +
102 const response: Response = createResponse(
103 serverConsumerManifest.moduleMap,
104 serverConsumerManifest.serverModuleMap,
@@ -105,6 +114,7 @@ function createFromNodeStream<T>(
114 __DEV__ && options && options.environmentName
115 ? options.environmentName
116 : undefined,
117 + debugChannel,
118 );
119
120 if (__DEV__ && options && options.debugChannel) {