Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Flight] Emit debug info for a Server Component #28272

Merged
merged 1 commit into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why Array? Could there be a collection with a size > 1 for a chunk?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea. For one chunk you can have multiple Server Components. Like a Server Component rendering another Server Component and so on. There will also be possible to have other steps inside like promises resolving. There will also be logs which has to be associated with the right component so order matters.

Basically all kinds of things that happened will be expressed in the order they happened.


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();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@acdlite I used a new mutable state if it wasn't already created.

I really only need this for DEV mode but seems generally useful. We could align Fizz/Fiber too but not necessary.

thenableState = null;
return state;
}
Expand Down
Loading