[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.
This commit is contained in:
Hendrik Liebau
2025-08-27 13:50:19 +02:00
committed by GitHub
parent 4123f6b771
commit 9c2e2b8475
18 changed files with 763 additions and 105 deletions

View File

@@ -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<ConsoleEntry>, // 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<T>(chunk: SomeChunk<T>): 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>(
): 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<number, SomeChunk<any>> = 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<any>).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(':');

View File

@@ -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,
);
}

View File

@@ -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<T>(
moduleBaseURL: string,
options?: Options,
): Thenable<T> {
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<T>(
__DEV__ && options && options.environmentName
? options.environmentName
: undefined,
debugChannel,
);
if (__DEV__ && options && options.debugChannel) {

View File

@@ -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<T>(
stream: ReadableStream,
options?: Options,
): Thenable<T> {
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<T>(
promiseForResponse: Promise<Response>,
options?: Options,
): Thenable<T> {
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 (

View File

@@ -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,
);
}

View File

@@ -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<T>(
stream: Readable,
options?: Options,
): Thenable<T> {
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<T>(
__DEV__ && options && options.environmentName
? options.environmentName
: undefined,
debugChannel,
);
if (__DEV__ && options && options.debugChannel) {

View File

@@ -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 <span>{children}</span>;
@@ -163,4 +177,74 @@ describe('ReactFlightTurbopackDOMBrowser', () => {
expect(container.innerHTML).toBe('<div>Hi</div>');
});
it('can transport debug info through a dedicated debug channel', async () => {
let ownerStack;
const ClientComponent = clientExports(() => {
ownerStack = React.captureOwnerStack ? React.captureOwnerStack() : null;
return <p>Hi</p>;
});
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(<ClientRoot response={response} />);
});
if (__DEV__) {
expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
}
expect(container.innerHTML).toBe('<p>Hi</p>');
});
});

View File

@@ -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(
<ClientRoot response={response} />,
{
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',
);
});
});

View File

@@ -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(
<ClientRoot response={response} />,
{
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(() =>

View File

@@ -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,
);
}

View File

@@ -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,
);
}

View File

@@ -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<T>(
serverConsumerManifest: ServerConsumerManifest,
options?: Options,
): Thenable<T> {
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<T>(
__DEV__ && options && options.environmentName
? options.environmentName
: undefined,
debugChannel,
);
if (__DEV__ && options && options.debugChannel) {

View File

@@ -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 <span>{children}</span>;
@@ -2767,4 +2776,74 @@ describe('ReactFlightDOMBrowser', () => {
expect(container.innerHTML).toBe('<div>Hi</div>');
});
it('can transport debug info through a dedicated debug channel', async () => {
let ownerStack;
const ClientComponent = clientExports(() => {
ownerStack = React.captureOwnerStack ? React.captureOwnerStack() : null;
return <p>Hi</p>;
});
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(<ClientRoot response={response} />);
});
if (__DEV__) {
expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
}
expect(container.innerHTML).toBe('<p>Hi</p>');
});
});

View File

@@ -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(
<ClientRoot response={response} />,
{
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',
);
});
});

View File

@@ -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(
<ClientRoot response={response} />,
{
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(() =>

View File

@@ -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,
);
}

View File

@@ -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,
);
}

View File

@@ -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<T>(
serverConsumerManifest: ServerConsumerManifest,
options?: Options,
): Thenable<T> {
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<T>(
__DEV__ && options && options.environmentName
? options.environmentName
: undefined,
debugChannel,
);
if (__DEV__ && options && options.debugChannel) {