Skip to content

Commit

Permalink
[Flight] Emit debug info for a Server Component (#28272)
Browse files Browse the repository at this point in the history
This adds a new DEV-only row type `D` for DebugInfo. If we see this in
prod, that's an error. It can contain extra debug information about the
Server Components (or Promises) that were compiled away during the
server render. It's DEV-only since this can contain sensitive
information (similar to errors) and since it'll be a lot of data, but
it's worth using the same stream for simplicity rather than a
side-channel.

In this first pass it's just the Server Component's name but I'll keep
adding more debug info to the stream, and it won't always just be a
Server Component's stack frame.

Each row can get more debug rows data streaming in as it resolves and
renders multiple server components in a row.

The data structure is just a side-channel and it would be perfectly fine
to ignore the D rows and it would behave the same as prod. With this
data structure though the data is associated with the row ID / chunk, so
you can't have inline meta data. This means that an inline Server
Component that doesn't get an ID otherwise will need to be outlined. The
way I outline Server Components is using a direct reference where it's
synchronous though so on the client side it behaves the same (i.e.
there's no lazy wrapper in this case).

In most cases the `_debugInfo` is on the Promises that we yield and we
also expose this on the `React.Lazy` wrappers. In the case where it's a
synchronous render it might attach this data to Elements or Arrays
(fragments) too.

In a future PR I'll wire this information up with Fiber to stash it in
the Fiber data structures so that DevTools can pick it up. This property
and the information in it is not limited to Server Components. The name
of the property that we look for probably shouldn't be `_debugInfo`
since it's semi-public. Should consider the name we use for that.

If it's a synchronous render that returns a string or number (text node)
then we don't have anywhere to attach them to. We could add a
`React.Lazy` wrapper for those but I chose to prioritize keeping the
data structure untouched. Can be useful if you use Server Components to
render data instead of React Nodes.
  • Loading branch information
sebmarkbage authored Feb 8, 2024
1 parent 37d901e commit b229f54
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 11 deletions.
88 changes: 85 additions & 3 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,53 +76,63 @@ const RESOLVED_MODULE = 'resolved_module';
const INITIALIZED = 'fulfilled';
const ERRORED = 'rejected';

// Dev-only
type ReactDebugInfo = Array<{+name?: string}>;

type PendingChunk<T> = {
status: 'pending',
value: null | Array<(T) => mixed>,
reason: null | Array<(mixed) => mixed>,
_response: Response,
_debugInfo?: null | ReactDebugInfo,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type BlockedChunk<T> = {
status: 'blocked',
value: null | Array<(T) => mixed>,
reason: null | Array<(mixed) => mixed>,
_response: Response,
_debugInfo?: null | ReactDebugInfo,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type CyclicChunk<T> = {
status: 'cyclic',
value: null | Array<(T) => mixed>,
reason: null | Array<(mixed) => mixed>,
_response: Response,
_debugInfo?: null | ReactDebugInfo,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
status: 'resolved_model',
value: UninitializedModel,
reason: null,
_response: Response,
_debugInfo?: null | ReactDebugInfo,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type ResolvedModuleChunk<T> = {
status: 'resolved_module',
value: ClientReference<T>,
reason: null,
_response: Response,
_debugInfo?: null | ReactDebugInfo,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type InitializedChunk<T> = {
status: 'fulfilled',
value: T,
reason: null,
_response: Response,
_debugInfo?: null | ReactDebugInfo,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type ErroredChunk<T> = {
status: 'rejected',
value: null,
reason: mixed,
_response: Response,
_debugInfo?: null | ReactDebugInfo,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type SomeChunk<T> =
Expand All @@ -140,6 +150,9 @@ function Chunk(status: any, value: any, reason: any, response: Response) {
this.value = value;
this.reason = reason;
this._response = response;
if (__DEV__) {
this._debugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Chunk.prototype = (Object.create(Promise.prototype): any);
Expand Down Expand Up @@ -475,6 +488,13 @@ function createElement(
writable: true,
value: true, // This element has already been validated on the server.
});
// debugInfo contains Server Component debug information.
Object.defineProperty(element, '_debugInfo', {
configurable: false,
enumerable: false,
writable: true,
value: null,
});
}
return element;
}
Expand All @@ -487,6 +507,12 @@ function createLazyChunkWrapper<T>(
_payload: chunk,
_init: readChunk,
};
if (__DEV__) {
// Ensure we have a live array to track future debug info.
const chunkDebugInfo: ReactDebugInfo =
chunk._debugInfo || (chunk._debugInfo = []);
lazyType._debugInfo = chunkDebugInfo;
}
return lazyType;
}

Expand Down Expand Up @@ -682,7 +708,33 @@ function parseModelString(
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED:
return chunk.value;
const chunkValue = chunk.value;
if (__DEV__ && chunk._debugInfo) {
// If we have a direct reference to an object that was rendered by a synchronous
// server component, it might have some debug info about how it was rendered.
// We forward this to the underlying object. This might be a React Element or
// an Array fragment.
// If this was a string / number return value we lose the debug info. We choose
// that tradeoff to allow sync server components to return plain values and not
// use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
if (
typeof chunkValue === 'object' &&
chunkValue !== null &&
(Array.isArray(chunkValue) ||
chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
!chunkValue._debugInfo
) {
// We should maybe use a unique symbol for arrays but this is a React owned array.
// $FlowFixMe[prop-missing]: This should be added to elements.
Object.defineProperty(chunkValue, '_debugInfo', {
configurable: false,
enumerable: false,
writable: true,
value: chunk._debugInfo,
});
}
}
return chunkValue;
case PENDING:
case BLOCKED:
case CYCLIC:
Expand Down Expand Up @@ -959,6 +1011,24 @@ function resolveHint<Code: HintCode>(
dispatchHint(code, hintModel);
}

function resolveDebugInfo(
response: Response,
id: number,
debugInfo: {name: string},
): void {
if (!__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'resolveDebugInfo should never be called in production mode. This is a bug in React.',
);
}
const chunk = getChunk(response, id);
const chunkDebugInfo: ReactDebugInfo =
chunk._debugInfo || (chunk._debugInfo = []);
chunkDebugInfo.push(debugInfo);
}

function mergeBuffer(
buffer: Array<Uint8Array>,
lastChunk: Uint8Array,
Expand Down Expand Up @@ -1052,7 +1122,7 @@ function processFullRow(
case 70 /* "F" */:
resolveTypedArray(response, id, buffer, chunk, Float32Array, 4);
return;
case 68 /* "D" */:
case 100 /* "d" */:
resolveTypedArray(response, id, buffer, chunk, Float64Array, 8);
return;
case 78 /* "N" */:
Expand Down Expand Up @@ -1102,6 +1172,18 @@ function processFullRow(
resolveText(response, id, row);
return;
}
case 68 /* "D" */: {
if (__DEV__) {
const debugInfo = JSON.parse(row);
resolveDebugInfo(response, id, debugInfo);
return;
}
throw new Error(
'Failed to read a RSC payload created by a development version of React ' +
'on the server while using a production version on the client. Always use ' +
'matching versions on the server and the client.',
);
}
case 80 /* "P" */: {
if (enablePostpone) {
if (__DEV__) {
Expand Down Expand Up @@ -1165,7 +1247,7 @@ export function processBinaryChunk(
resolvedRowTag === 76 /* "L" */ ||
resolvedRowTag === 108 /* "l" */ ||
resolvedRowTag === 70 /* "F" */ ||
resolvedRowTag === 68 /* "D" */ ||
resolvedRowTag === 100 /* "d" */ ||
resolvedRowTag === 78 /* "N" */ ||
resolvedRowTag === 109 /* "m" */ ||
resolvedRowTag === 86)) /* "V" */
Expand Down
30 changes: 30 additions & 0 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,42 @@ describe('ReactFlight', () => {
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
const greeting = rootModel.greeting;
expect(greeting._debugInfo).toEqual(
__DEV__ ? [{name: 'Greeting'}] : undefined,
);
ReactNoop.render(greeting);
});

expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
});

it('can render a shared forwardRef Component', async () => {
const Greeting = React.forwardRef(function Greeting(
{firstName, lastName},
ref,
) {
return (
<span ref={ref}>
Hello, {firstName} {lastName}
</span>
);
});

const root = <Greeting firstName="Seb" lastName="Smith" />;

const transport = ReactNoopFlightServer.render(root);

await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(promise._debugInfo).toEqual(
__DEV__ ? [{name: 'Greeting'}] : undefined,
);
ReactNoop.render(await promise);
});

expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
});

it('can render an iterable as an array', async () => {
function ItemListClient(props) {
return <span>{props.items}</span>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ describe('ReactFlightDOMEdge', () => {
<ServerComponent recurse={20} />,
);
const serializedContent = await readResult(stream);
expect(serializedContent.length).toBeLessThan(150);
const expectedDebugInfoSize = __DEV__ ? 30 * 20 : 0;
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
});

// @gate enableBinaryFlight
Expand Down
7 changes: 5 additions & 2 deletions packages/react-server/src/ReactFlightHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ export function prepareToUseHooksForComponent(
thenableState = prevThenableState;
}

export function getThenableStateAfterSuspending(): null | ThenableState {
const state = thenableState;
export function getThenableStateAfterSuspending(): ThenableState {
// If you use() to Suspend this should always exist but if you throw a Promise instead,
// which is not really supported anymore, it will be empty. We use the empty set as a
// marker to know if this was a replay of the same component or first attempt.
const state = thenableState || createThenableState();
thenableState = null;
return state;
}
Expand Down
Loading

0 comments on commit b229f54

Please sign in to comment.