[Flight] Transfer Debug Info in Server-to-Server Flight Requests (#28275)

A Flight Server can be a consumer of a stream from another Server. In
this case the meta data is attached to debugInfo properties on lazy,
Promises, Arrays or Elements that might in turn get forwarded to the
next stream. In this case we want to forward this debug information to
the client in the stream.

I also added a DEV only `environmentName` option to the Flight Server.
This lets you name the server that is producing the debug info so that
you can trace the origin of where that component is executing. This
defaults to `"server"`. DevTools could use this for badges or different
colors.
This commit is contained in:
Sebastian Markbåge
2024-02-12 13:38:14 -05:00
committed by GitHub
parent 947e7962ad
commit 629541bcc0
15 changed files with 186 additions and 13 deletions

View File

@@ -77,7 +77,7 @@ const INITIALIZED = 'fulfilled';
const ERRORED = 'rejected';
// Dev-only
type ReactDebugInfo = Array<{+name?: string}>;
type ReactDebugInfo = Array<{+name?: string, +env?: string}>;
type PendingChunk<T> = {
status: 'pending',

View File

@@ -187,7 +187,7 @@ describe('ReactFlight', () => {
const rootModel = await ReactNoopFlightClient.read(transport);
const greeting = rootModel.greeting;
expect(greeting._debugInfo).toEqual(
__DEV__ ? [{name: 'Greeting'}] : undefined,
__DEV__ ? [{name: 'Greeting', env: 'server'}] : undefined,
);
ReactNoop.render(greeting);
});
@@ -214,7 +214,7 @@ describe('ReactFlight', () => {
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(promise._debugInfo).toEqual(
__DEV__ ? [{name: 'Greeting'}] : undefined,
__DEV__ ? [{name: 'Greeting', env: 'server'}] : undefined,
);
ReactNoop.render(await promise);
});
@@ -1806,4 +1806,68 @@ describe('ReactFlight', () => {
expect(ReactNoop).toMatchRenderedOutput(<div>Ba</div>);
});
it('preserves debug info for server-to-server pass through', async () => {
function ThirdPartyLazyComponent() {
return <span>!</span>;
}
const lazy = React.lazy(async () => ({
default: <ThirdPartyLazyComponent />,
}));
function ThirdPartyComponent() {
return <span>stranger</span>;
}
function ServerComponent({transport}) {
// This is a Server Component that receives other Server Components from a third party.
const children = ReactNoopFlightClient.read(transport);
return <div>Hello, {children}</div>;
}
const promiseComponent = Promise.resolve(<ThirdPartyComponent />);
const thirdPartyTransport = ReactNoopFlightServer.render(
[promiseComponent, lazy],
{
environmentName: 'third-party',
},
);
// Wait for the lazy component to initialize
await 0;
const transport = ReactNoopFlightServer.render(
<ServerComponent transport={thirdPartyTransport} />,
);
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(promise._debugInfo).toEqual(
__DEV__ ? [{name: 'ServerComponent', env: 'server'}] : undefined,
);
const result = await promise;
const thirdPartyChildren = await result.props.children[1];
// We expect the debug info to be transferred from the inner stream to the outer.
expect(thirdPartyChildren[0]._debugInfo).toEqual(
__DEV__
? [{name: 'ThirdPartyComponent', env: 'third-party'}]
: undefined,
);
expect(thirdPartyChildren[1]._debugInfo).toEqual(
__DEV__
? [{name: 'ThirdPartyLazyComponent', env: 'third-party'}]
: undefined,
);
ReactNoop.render(result);
});
expect(ReactNoop).toMatchRenderedOutput(
<div>
Hello, <span>stranger</span>
<span>!</span>
</div>,
);
});
});

View File

@@ -68,8 +68,10 @@ const ReactNoopFlightServer = ReactFlightServer({
});
type Options = {
onError?: (error: mixed) => void,
environmentName?: string,
identifierPrefix?: string,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
};
function render(model: ReactClientValue, options?: Options): Destination {
@@ -80,6 +82,8 @@ function render(model: ReactClientValue, options?: Options): Destination {
bundlerConfig,
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
ReactNoopFlightServer.startWork(request);
ReactNoopFlightServer.startFlowing(request, destination);

View File

@@ -52,6 +52,7 @@ function createDrainHandler(destination: Destination, request: Request) {
}
type Options = {
environmentName?: string,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
identifierPrefix?: string,
@@ -73,6 +74,7 @@ function renderToPipeableStream(
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
let hasStartedFlowing = false;
startWork(request);

View File

@@ -50,6 +50,8 @@ function renderToDestination(
model,
null,
options ? options.onError : undefined,
undefined,
undefined,
);
startWork(request);
startFlowing(request, destination);

View File

@@ -34,6 +34,7 @@ export {
} from './ReactFlightTurbopackReferences';
type Options = {
environmentName?: string,
identifierPrefix?: string,
signal?: AbortSignal,
onError?: (error: mixed) => void,
@@ -51,6 +52,7 @@ function renderToReadableStream(
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
if (options && options.signal) {
const signal = options.signal;

View File

@@ -34,6 +34,7 @@ export {
} from './ReactFlightTurbopackReferences';
type Options = {
environmentName?: string,
identifierPrefix?: string,
signal?: AbortSignal,
onError?: (error: mixed) => void,
@@ -51,6 +52,7 @@ function renderToReadableStream(
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
if (options && options.signal) {
const signal = options.signal;

View File

@@ -49,6 +49,7 @@ function createDrainHandler(destination: Destination, request: Request) {
}
type Options = {
environmentName?: string,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
identifierPrefix?: string,
@@ -70,6 +71,7 @@ function renderToPipeableStream(
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
let hasStartedFlowing = false;
startWork(request);

View File

@@ -38,6 +38,7 @@ export {
} from './ReactFlightWebpackReferences';
type Options = {
environmentName?: string,
identifierPrefix?: string,
signal?: AbortSignal,
onError?: (error: mixed) => void,
@@ -55,6 +56,7 @@ function renderToReadableStream(
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
if (options && options.signal) {
const signal = options.signal;

View File

@@ -38,6 +38,7 @@ export {
} from './ReactFlightWebpackReferences';
type Options = {
environmentName?: string,
identifierPrefix?: string,
signal?: AbortSignal,
onError?: (error: mixed) => void,
@@ -55,6 +56,7 @@ function renderToReadableStream(
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
if (options && options.signal) {
const signal = options.signal;

View File

@@ -61,6 +61,7 @@ function createCancelHandler(request: Request, reason: string) {
}
type Options = {
environmentName?: string,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
identifierPrefix?: string,
@@ -82,6 +83,7 @@ function renderToPipeableStream(
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.environmentName : undefined,
);
let hasStartedFlowing = false;
startWork(request);

View File

@@ -286,7 +286,7 @@ describe('ReactFlightDOMEdge', () => {
<ServerComponent recurse={20} />,
);
const serializedContent = await readResult(stream);
const expectedDebugInfoSize = __DEV__ ? 30 * 20 : 0;
const expectedDebugInfoSize = __DEV__ ? 42 * 20 : 0;
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
});

View File

@@ -107,6 +107,9 @@ import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
initAsyncDebugInfo();
// Dev-only
type ReactDebugInfo = Array<{+name?: string, +env?: string}>;
const ObjectPrototype = Object.prototype;
type JSONValue =
@@ -199,6 +202,8 @@ export type Request = {
taintCleanupQueue: Array<string | bigint>,
onError: (error: mixed) => ?string,
onPostpone: (reason: string) => void,
// DEV-only
environmentName: string,
};
const {
@@ -251,6 +256,7 @@ export function createRequest(
onError: void | ((error: mixed) => ?string),
identifierPrefix?: string,
onPostpone: void | ((reason: string) => void),
environmentName: void | string,
): Request {
if (
ReactCurrentCache.current !== null &&
@@ -270,7 +276,7 @@ export function createRequest(
TaintRegistryPendingRequests.add(cleanupQueue);
}
const hints = createHints();
const request: Request = {
const request: Request = ({
status: OPEN,
flushScheduled: false,
fatalError: null,
@@ -295,7 +301,11 @@ export function createRequest(
taintCleanupQueue: cleanupQueue,
onError: onError === undefined ? defaultErrorHandler : onError,
onPostpone: onPostpone === undefined ? defaultPostponeHandler : onPostpone,
};
}: any);
if (__DEV__) {
request.environmentName =
environmentName === undefined ? 'server' : environmentName;
}
const rootTask = createTask(request, model, null, false, abortSet);
pingedTasks.push(rootTask);
return request;
@@ -325,6 +335,14 @@ function serializeThenable(
request.abortableTasks,
);
if (__DEV__) {
// If this came from Flight, forward any debug info into this new row.
const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo;
if (debugInfo) {
forwardDebugInfo(request, newTask.id, debugInfo);
}
}
switch (thenable.status) {
case 'fulfilled': {
// We have the resolved value, we can go ahead and schedule it for serialization.
@@ -475,6 +493,10 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) {
_payload: thenable,
_init: readThenable,
};
if (__DEV__) {
// If this came from React, transfer the debug info.
lazyType._debugInfo = (thenable: any)._debugInfo || [];
}
return lazyType;
}
@@ -504,7 +526,10 @@ function renderFunctionComponent<Props>(
const componentName =
(Component: any).displayName || Component.name || '';
request.pendingChunks++;
emitDebugChunk(request, debugID, {name: componentName});
emitDebugChunk(request, debugID, {
name: componentName,
env: request.environmentName,
});
}
}
@@ -552,6 +577,22 @@ function renderFragment(
task: Task,
children: $ReadOnlyArray<ReactClientValue>,
): ReactJSONValue {
if (__DEV__) {
const debugInfo: ?ReactDebugInfo = (children: any)._debugInfo;
if (debugInfo) {
// If this came from Flight, forward any debug info into this new row.
if (debugID === null) {
// We don't have a chunk to assign debug info. We need to outline this
// component to assign it an ID.
return outlineTask(request, task);
} else {
// Forward any debug info we have the first time we see it.
// We do this after init so that we have received all the debug info
// from the server by the time we emit it.
forwardDebugInfo(request, debugID, debugInfo);
}
}
}
if (!enableServerComponentKeys) {
return children;
}
@@ -1210,6 +1251,22 @@ function renderModelDestructive(
}
const element: React$Element<any> = (value: any);
if (__DEV__) {
const debugInfo: ?ReactDebugInfo = (value: any)._debugInfo;
if (debugInfo) {
// If this came from Flight, forward any debug info into this new row.
if (debugID === null) {
// We don't have a chunk to assign debug info. We need to outline this
// component to assign it an ID.
return outlineTask(request, task);
} else {
// Forward any debug info we have the first time we see it.
forwardDebugInfo(request, debugID, debugInfo);
}
}
}
// Attempt to render the Server Component.
return renderElement(
request,
@@ -1222,9 +1279,30 @@ function renderModelDestructive(
);
}
case REACT_LAZY_TYPE: {
const payload = (value: any)._payload;
const init = (value: any)._init;
// Reset the task's thenable state before continuing. If there was one, it was
// from suspending the lazy before.
task.thenableState = null;
const lazy: LazyComponent<any, any> = (value: any);
const payload = lazy._payload;
const init = lazy._init;
const resolvedModel = init(payload);
if (__DEV__) {
const debugInfo: ?ReactDebugInfo = lazy._debugInfo;
if (debugInfo) {
// If this came from Flight, forward any debug info into this new row.
if (debugID === null) {
// We don't have a chunk to assign debug info. We need to outline this
// component to assign it an ID.
return outlineTask(request, task);
} else {
// Forward any debug info we have the first time we see it.
// We do this after init so that we have received all the debug info
// from the server by the time we emit it.
forwardDebugInfo(request, debugID, debugInfo);
}
}
}
return renderModelDestructive(
request,
task,
@@ -1653,7 +1731,7 @@ function emitModelChunk(request: Request, id: number, json: string): void {
function emitDebugChunk(
request: Request,
id: number,
debugInfo: {name: string},
debugInfo: {+name?: string, +env?: string},
): void {
if (!__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
@@ -1669,6 +1747,17 @@ function emitDebugChunk(
request.completedRegularChunks.push(processedChunk);
}
function forwardDebugInfo(
request: Request,
id: number,
debugInfo: ReactDebugInfo,
) {
for (let i = 0; i < debugInfo.length; i++) {
request.pendingChunks++;
emitDebugChunk(request, id, debugInfo[i]);
}
}
const emptyRoot = {};
function retryTask(request: Request, task: Task): void {

View File

@@ -46,7 +46,7 @@ export type LazyComponent<T, P> = {
$$typeof: symbol | number,
_payload: P,
_init: (payload: P) => T,
_debugInfo?: null | Array<{+name?: string}>,
_debugInfo?: null | Array<{+name?: string, +env?: string}>,
};
function lazyInitializer<T>(payload: Payload<T>): T {

View File

@@ -85,7 +85,7 @@ describe('ReactFetch', () => {
const promise = render(Component);
expect(await promise).toMatchInlineSnapshot(`"GET world []"`);
expect(promise._debugInfo).toEqual(
__DEV__ ? [{name: 'Component'}] : undefined,
__DEV__ ? [{name: 'Component', env: 'server'}] : undefined,
);
expect(fetchCount).toBe(1);
});