[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) {