Skip to content

Commit b71bb24

Browse files
sebmarkbageAndyPengc12
authored andcommitted
[Flight] Emit debug info for a Server Component (facebook#28272)
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.
1 parent 3751326 commit b71bb24

File tree

10 files changed

+223
-11
lines changed

10 files changed

+223
-11
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 85 additions & 3 deletions
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

Lines changed: 30 additions & 0 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 5 additions & 2 deletions
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)