Skip to content

Commit

Permalink
Emit debug info for a Server Component
Browse files Browse the repository at this point in the history
  • Loading branch information
sebmarkbage committed Feb 7, 2024
1 parent a1ace9d commit 62da24a
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 11 deletions.
89 changes: 86 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,14 @@ 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,
});
// self and source are DEV only properties.
Object.defineProperty(element, '_self', {
configurable: false,
enumerable: false,
Expand All @@ -499,6 +520,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 @@ -694,7 +721,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 @@ -971,6 +1024,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 @@ -1064,7 +1135,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 @@ -1114,6 +1185,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 @@ -1177,7 +1260,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 62da24a

Please sign in to comment.