Skip to content

Commit 3cfd12b

Browse files
committed
Emit debug info for a Server Component
1 parent 37d901e commit 3cfd12b

File tree

10 files changed

+226
-11
lines changed

10 files changed

+226
-11
lines changed

packages/react-client/src/ReactFlightClient.js

+85-3
Original file line numberDiff line numberDiff line change
@@ -76,53 +76,63 @@ const RESOLVED_MODULE = 'resolved_module';
7676
const INITIALIZED = 'fulfilled';
7777
const ERRORED = 'rejected';
7878

79+
// Dev-only
80+
type ReactDebugInfo = Array<{+name?: string}>;
81+
7982
type PendingChunk<T> = {
8083
status: 'pending',
8184
value: null | Array<(T) => mixed>,
8285
reason: null | Array<(mixed) => mixed>,
8386
_response: Response,
87+
_debugInfo?: null | ReactDebugInfo,
8488
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
8589
};
8690
type BlockedChunk<T> = {
8791
status: 'blocked',
8892
value: null | Array<(T) => mixed>,
8993
reason: null | Array<(mixed) => mixed>,
9094
_response: Response,
95+
_debugInfo?: null | ReactDebugInfo,
9196
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
9297
};
9398
type CyclicChunk<T> = {
9499
status: 'cyclic',
95100
value: null | Array<(T) => mixed>,
96101
reason: null | Array<(mixed) => mixed>,
97102
_response: Response,
103+
_debugInfo?: null | ReactDebugInfo,
98104
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
99105
};
100106
type ResolvedModelChunk<T> = {
101107
status: 'resolved_model',
102108
value: UninitializedModel,
103109
reason: null,
104110
_response: Response,
111+
_debugInfo?: null | ReactDebugInfo,
105112
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
106113
};
107114
type ResolvedModuleChunk<T> = {
108115
status: 'resolved_module',
109116
value: ClientReference<T>,
110117
reason: null,
111118
_response: Response,
119+
_debugInfo?: null | ReactDebugInfo,
112120
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
113121
};
114122
type InitializedChunk<T> = {
115123
status: 'fulfilled',
116124
value: T,
117125
reason: null,
118126
_response: Response,
127+
_debugInfo?: null | ReactDebugInfo,
119128
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
120129
};
121130
type ErroredChunk<T> = {
122131
status: 'rejected',
123132
value: null,
124133
reason: mixed,
125134
_response: Response,
135+
_debugInfo?: null | ReactDebugInfo,
126136
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
127137
};
128138
type SomeChunk<T> =
@@ -140,6 +150,9 @@ function Chunk(status: any, value: any, reason: any, response: Response) {
140150
this.value = value;
141151
this.reason = reason;
142152
this._response = response;
153+
if (__DEV__) {
154+
this._debugInfo = null;
155+
}
143156
}
144157
// We subclass Promise.prototype so that we get other methods like .catch
145158
Chunk.prototype = (Object.create(Promise.prototype): any);
@@ -475,6 +488,13 @@ function createElement(
475488
writable: true,
476489
value: true, // This element has already been validated on the server.
477490
});
491+
// debugInfo contains Server Component debug information.
492+
Object.defineProperty(element, '_debugInfo', {
493+
configurable: false,
494+
enumerable: false,
495+
writable: true,
496+
value: null,
497+
});
478498
}
479499
return element;
480500
}
@@ -487,6 +507,12 @@ function createLazyChunkWrapper<T>(
487507
_payload: chunk,
488508
_init: readChunk,
489509
};
510+
if (__DEV__) {
511+
// Ensure we have a live array to track future debug info.
512+
const chunkDebugInfo: ReactDebugInfo =
513+
chunk._debugInfo || (chunk._debugInfo = []);
514+
lazyType._debugInfo = chunkDebugInfo;
515+
}
490516
return lazyType;
491517
}
492518

@@ -682,7 +708,33 @@ function parseModelString(
682708
// The status might have changed after initialization.
683709
switch (chunk.status) {
684710
case INITIALIZED:
685-
return chunk.value;
711+
const chunkValue = chunk.value;
712+
if (__DEV__ && chunk._debugInfo) {
713+
// If we have a direct reference to an object that was rendered by a synchronous
714+
// server component, it might have some debug info about how it was rendered.
715+
// We forward this to the underlying object. This might be a React Element or
716+
// an Array fragment.
717+
// If this was a string / number return value we lose the debug info. We choose
718+
// that tradeoff to allow sync server components to return plain values and not
719+
// use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
720+
if (
721+
typeof chunkValue === 'object' &&
722+
chunkValue !== null &&
723+
(Array.isArray(chunkValue) ||
724+
chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
725+
!chunkValue._debugInfo
726+
) {
727+
// We should maybe use a unique symbol for arrays but this is a React owned array.
728+
// $FlowFixMe[prop-missing]: This should be added to elements.
729+
Object.defineProperty(chunkValue, '_debugInfo', {
730+
configurable: false,
731+
enumerable: false,
732+
writable: true,
733+
value: chunk._debugInfo,
734+
});
735+
}
736+
}
737+
return chunkValue;
686738
case PENDING:
687739
case BLOCKED:
688740
case CYCLIC:
@@ -959,6 +1011,24 @@ function resolveHint<Code: HintCode>(
9591011
dispatchHint(code, hintModel);
9601012
}
9611013

1014+
function resolveDebugInfo(
1015+
response: Response,
1016+
id: number,
1017+
debugInfo: {name: string},
1018+
): void {
1019+
if (!__DEV__) {
1020+
// These errors should never make it into a build so we don't need to encode them in codes.json
1021+
// eslint-disable-next-line react-internal/prod-error-codes
1022+
throw new Error(
1023+
'resolveDebugInfo should never be called in production mode. This is a bug in React.',
1024+
);
1025+
}
1026+
const chunk = getChunk(response, id);
1027+
const chunkDebugInfo: ReactDebugInfo =
1028+
chunk._debugInfo || (chunk._debugInfo = []);
1029+
chunkDebugInfo.push(debugInfo);
1030+
}
1031+
9621032
function mergeBuffer(
9631033
buffer: Array<Uint8Array>,
9641034
lastChunk: Uint8Array,
@@ -1052,7 +1122,7 @@ function processFullRow(
10521122
case 70 /* "F" */:
10531123
resolveTypedArray(response, id, buffer, chunk, Float32Array, 4);
10541124
return;
1055-
case 68 /* "D" */:
1125+
case 100 /* "d" */:
10561126
resolveTypedArray(response, id, buffer, chunk, Float64Array, 8);
10571127
return;
10581128
case 78 /* "N" */:
@@ -1102,6 +1172,18 @@ function processFullRow(
11021172
resolveText(response, id, row);
11031173
return;
11041174
}
1175+
case 68 /* "D" */: {
1176+
if (__DEV__) {
1177+
const debugInfo = JSON.parse(row);
1178+
resolveDebugInfo(response, id, debugInfo);
1179+
return;
1180+
}
1181+
throw new Error(
1182+
'Failed to read a RSC payload created by a development version of React ' +
1183+
'on the server while using a production version on the client. Always use ' +
1184+
'matching versions on the server and the client.',
1185+
);
1186+
}
11051187
case 80 /* "P" */: {
11061188
if (enablePostpone) {
11071189
if (__DEV__) {
@@ -1165,7 +1247,7 @@ export function processBinaryChunk(
11651247
resolvedRowTag === 76 /* "L" */ ||
11661248
resolvedRowTag === 108 /* "l" */ ||
11671249
resolvedRowTag === 70 /* "F" */ ||
1168-
resolvedRowTag === 68 /* "D" */ ||
1250+
resolvedRowTag === 100 /* "d" */ ||
11691251
resolvedRowTag === 78 /* "N" */ ||
11701252
resolvedRowTag === 109 /* "m" */ ||
11711253
resolvedRowTag === 86)) /* "V" */

packages/react-client/src/__tests__/ReactFlight-test.js

+30
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,42 @@ describe('ReactFlight', () => {
186186
await act(async () => {
187187
const rootModel = await ReactNoopFlightClient.read(transport);
188188
const greeting = rootModel.greeting;
189+
expect(greeting._debugInfo).toEqual(
190+
__DEV__ ? [{name: 'Greeting'}] : undefined,
191+
);
189192
ReactNoop.render(greeting);
190193
});
191194

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

198+
it('can render a shared forwardRef Component', async () => {
199+
const Greeting = React.forwardRef(function Greeting(
200+
{firstName, lastName},
201+
ref,
202+
) {
203+
return (
204+
<span ref={ref}>
205+
Hello, {firstName} {lastName}
206+
</span>
207+
);
208+
});
209+
210+
const root = <Greeting firstName="Seb" lastName="Smith" />;
211+
212+
const transport = ReactNoopFlightServer.render(root);
213+
214+
await act(async () => {
215+
const promise = ReactNoopFlightClient.read(transport);
216+
expect(promise._debugInfo).toEqual(
217+
__DEV__ ? [{name: 'Greeting'}] : undefined,
218+
);
219+
ReactNoop.render(await promise);
220+
});
221+
222+
expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
223+
});
224+
195225
it('can render an iterable as an array', async () => {
196226
function ItemListClient(props) {
197227
return <span>{props.items}</span>;

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ describe('ReactFlightDOMEdge', () => {
286286
<ServerComponent recurse={20} />,
287287
);
288288
const serializedContent = await readResult(stream);
289-
expect(serializedContent.length).toBeLessThan(150);
289+
const expectedDebugInfoSize = __DEV__ ? 30 * 20 : 0;
290+
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
290291
});
291292

292293
// @gate enableBinaryFlight

packages/react-server/src/ReactFlightHooks.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ export function prepareToUseHooksForComponent(
3737
thenableState = prevThenableState;
3838
}
3939

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

0 commit comments

Comments
 (0)