[Flight] Transfer Debug Info in Server-to-Server Flight Requests (#28275)
A Flight Server can be a consumer of a stream from another Server. In this case the meta data is attached to debugInfo properties on lazy, Promises, Arrays or Elements that might in turn get forwarded to the next stream. In this case we want to forward this debug information to the client in the stream. I also added a DEV only `environmentName` option to the Flight Server. This lets you name the server that is producing the debug info so that you can trace the origin of where that component is executing. This defaults to `"server"`. DevTools could use this for badges or different colors.
Sebastian Markbåge committed
Feb 12, 2024 at 13:38 UTC
629541bcc09fc7c0cc5c257541d084ee27457512
15 files changed
+186
-13
packages/react-client/src/ReactFlightClient.js
+1
-1
@@ -77,7 +77,7 @@ const INITIALIZED = 'fulfilled';
77
const ERRORED = 'rejected';
78
79
// Dev-only
80
-type ReactDebugInfo = Array<{+name?: string}>;
80
+type ReactDebugInfo = Array<{+name?: string, +env?: string}>;
81
82
type PendingChunk<T> = {
83
status: 'pending',
packages/react-client/src/__tests__/ReactFlight-test.js
+66
-2
@@ -187,7 +187,7 @@ describe('ReactFlight', () => {
187
const rootModel = await ReactNoopFlightClient.read(transport);
188
const greeting = rootModel.greeting;
189
expect(greeting._debugInfo).toEqual(
190
- __DEV__ ? [{name: 'Greeting'}] : undefined,
190
+ __DEV__ ? [{name: 'Greeting', env: 'server'}] : undefined,
191
);
192
ReactNoop.render(greeting);
193
});
@@ -214,7 +214,7 @@ describe('ReactFlight', () => {
214
await act(async () => {
215
const promise = ReactNoopFlightClient.read(transport);
216
expect(promise._debugInfo).toEqual(
217
- __DEV__ ? [{name: 'Greeting'}] : undefined,
217
+ __DEV__ ? [{name: 'Greeting', env: 'server'}] : undefined,
218
);
219
ReactNoop.render(await promise);
220
});
@@ -1806,4 +1806,68 @@ describe('ReactFlight', () => {
1806
1807
expect(ReactNoop).toMatchRenderedOutput(<div>Ba</div>);
1808
});
1809
+
1810
+ it('preserves debug info for server-to-server pass through', async () => {
1811
+ function ThirdPartyLazyComponent() {
1812
+ return <span>!</span>;
1813
+ }
1814
+
1815
+ const lazy = React.lazy(async () => ({
1816
+ default: <ThirdPartyLazyComponent />,
1817
+ }));
1818
+
1819
+ function ThirdPartyComponent() {
1820
+ return <span>stranger</span>;
1821
+ }
1822
+
1823
+ function ServerComponent({transport}) {
1824
+ // This is a Server Component that receives other Server Components from a third party.
1825
+ const children = ReactNoopFlightClient.read(transport);
1826
+ return <div>Hello, {children}</div>;
1827
+ }
1828
+
1829
+ const promiseComponent = Promise.resolve(<ThirdPartyComponent />);
1830
+
1831
+ const thirdPartyTransport = ReactNoopFlightServer.render(
1832
+ [promiseComponent, lazy],
1833
+ {
1834
+ environmentName: 'third-party',
1835
+ },
1836
+ );
1837
+
1838
+ // Wait for the lazy component to initialize
1839
+ await 0;
1840
+
1841
+ const transport = ReactNoopFlightServer.render(
1842
+ <ServerComponent transport={thirdPartyTransport} />,
1843
+ );
1844
+
1845
+ await act(async () => {
1846
+ const promise = ReactNoopFlightClient.read(transport);
1847
+ expect(promise._debugInfo).toEqual(
1848
+ __DEV__ ? [{name: 'ServerComponent', env: 'server'}] : undefined,
1849
+ );
1850
+ const result = await promise;
1851
+ const thirdPartyChildren = await result.props.children[1];
1852
+ // We expect the debug info to be transferred from the inner stream to the outer.
1853
+ expect(thirdPartyChildren[0]._debugInfo).toEqual(
1854
+ __DEV__
1855
+ ? [{name: 'ThirdPartyComponent', env: 'third-party'}]
1856
+ : undefined,
1857
+ );
1858
+ expect(thirdPartyChildren[1]._debugInfo).toEqual(
1859
+ __DEV__
1860
+ ? [{name: 'ThirdPartyLazyComponent', env: 'third-party'}]
1861
+ : undefined,
1862
+ );
1863
+ ReactNoop.render(result);
1864
+ });
1865
+
1866
+ expect(ReactNoop).toMatchRenderedOutput(
1867
+ <div>
1868
+ Hello, <span>stranger</span>
1869
+ <span>!</span>
1870
+ </div>,
1871
+ );
1872
+ });
1873
});
packages/react-noop-renderer/src/ReactNoopFlightServer.js
+5
-1
@@ -68,8 +68,10 @@ const ReactNoopFlightServer = ReactFlightServer({
68
});
69
70
type Options = {
71
- onError?: (error: mixed) => void,
71
+ environmentName?: string,
72
identifierPrefix?: string,
73
+ onError?: (error: mixed) => void,
74
+ onPostpone?: (reason: string) => void,
75
};
76
77
function render(model: ReactClientValue, options?: Options): Destination {
@@ -80,6 +82,8 @@ function render(model: ReactClientValue, options?: Options): Destination {
82
bundlerConfig,
83
options ? options.onError : undefined,
84
options ? options.identifierPrefix : undefined,
85
+ options ? options.onPostpone : undefined,
86
+ options ? options.environmentName : undefined,
87
);
88
ReactNoopFlightServer.startWork(request);
89
ReactNoopFlightServer.startFlowing(request, destination);
packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+2
@@ -52,6 +52,7 @@ function createDrainHandler(destination: Destination, request: Request) {
52
}
53
54
type Options = {
55
+ environmentName?: string,
56
onError?: (error: mixed) => void,
57
onPostpone?: (reason: string) => void,
58
identifierPrefix?: string,
@@ -73,6 +74,7 @@ function renderToPipeableStream(
74
options ? options.onError : undefined,
75
options ? options.identifierPrefix : undefined,
76
options ? options.onPostpone : undefined,
77
+ options ? options.environmentName : undefined,
78
);
79
let hasStartedFlowing = false;
80
startWork(request);
packages/react-server-dom-fb/src/ReactFlightDOMServerFB.js
+2
@@ -50,6 +50,8 @@ function renderToDestination(
50
model,
51
null,
52
options ? options.onError : undefined,
53
+ undefined,
54
+ undefined,
55
);
56
startWork(request);
57
startFlowing(request, destination);
packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js
+2
@@ -34,6 +34,7 @@ export {
34
} from './ReactFlightTurbopackReferences';
35
36
type Options = {
37
+ environmentName?: string,
38
identifierPrefix?: string,
39
signal?: AbortSignal,
40
onError?: (error: mixed) => void,
@@ -51,6 +52,7 @@ function renderToReadableStream(
52
options ? options.onError : undefined,
53
options ? options.identifierPrefix : undefined,
54
options ? options.onPostpone : undefined,
55
+ options ? options.environmentName : undefined,
56
);
57
if (options && options.signal) {
58
const signal = options.signal;
packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js
+2
@@ -34,6 +34,7 @@ export {
34
} from './ReactFlightTurbopackReferences';
35
36
type Options = {
37
+ environmentName?: string,
38
identifierPrefix?: string,
39
signal?: AbortSignal,
40
onError?: (error: mixed) => void,
@@ -51,6 +52,7 @@ function renderToReadableStream(
52
options ? options.onError : undefined,
53
options ? options.identifierPrefix : undefined,
54
options ? options.onPostpone : undefined,
55
+ options ? options.environmentName : undefined,
56
);
57
if (options && options.signal) {
58
const signal = options.signal;
packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js
+2
@@ -49,6 +49,7 @@ function createDrainHandler(destination: Destination, request: Request) {
49
}
50
51
type Options = {
52
+ environmentName?: string,
53
onError?: (error: mixed) => void,
54
onPostpone?: (reason: string) => void,
55
identifierPrefix?: string,
@@ -70,6 +71,7 @@ function renderToPipeableStream(
71
options ? options.onError : undefined,
72
options ? options.identifierPrefix : undefined,
73
options ? options.onPostpone : undefined,
74
+ options ? options.environmentName : undefined,
75
);
76
let hasStartedFlowing = false;
77
startWork(request);
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+2
@@ -38,6 +38,7 @@ export {
38
} from './ReactFlightWebpackReferences';
39
40
type Options = {
41
+ environmentName?: string,
42
identifierPrefix?: string,
43
signal?: AbortSignal,
44
onError?: (error: mixed) => void,
@@ -55,6 +56,7 @@ function renderToReadableStream(
56
options ? options.onError : undefined,
57
options ? options.identifierPrefix : undefined,
58
options ? options.onPostpone : undefined,
59
+ options ? options.environmentName : undefined,
60
);
61
if (options && options.signal) {
62
const signal = options.signal;
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+2
@@ -38,6 +38,7 @@ export {
38
} from './ReactFlightWebpackReferences';
39
40
type Options = {
41
+ environmentName?: string,
42
identifierPrefix?: string,
43
signal?: AbortSignal,
44
onError?: (error: mixed) => void,
@@ -55,6 +56,7 @@ function renderToReadableStream(
56
options ? options.onError : undefined,
57
options ? options.identifierPrefix : undefined,
58
options ? options.onPostpone : undefined,
59
+ options ? options.environmentName : undefined,
60
);
61
if (options && options.signal) {
62
const signal = options.signal;
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+2
@@ -61,6 +61,7 @@ function createCancelHandler(request: Request, reason: string) {
61
}
62
63
type Options = {
64
+ environmentName?: string,
65
onError?: (error: mixed) => void,
66
onPostpone?: (reason: string) => void,
67
identifierPrefix?: string,
@@ -82,6 +83,7 @@ function renderToPipeableStream(
83
options ? options.onError : undefined,
84
options ? options.identifierPrefix : undefined,
85
options ? options.onPostpone : undefined,
86
+ options ? options.environmentName : undefined,
87
);
88
let hasStartedFlowing = false;
89
startWork(request);
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+1
-1
@@ -286,7 +286,7 @@ describe('ReactFlightDOMEdge', () => {
286
<ServerComponent recurse={20} />,
287
);
288
const serializedContent = await readResult(stream);
289
- const expectedDebugInfoSize = __DEV__ ? 30 * 20 : 0;
289
+ const expectedDebugInfoSize = __DEV__ ? 42 * 20 : 0;
290
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
291
});
292
packages/react-server/src/ReactFlightServer.js
+95
-6
@@ -107,6 +107,9 @@ import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
107
108
initAsyncDebugInfo();
109
110
+// Dev-only
111
+type ReactDebugInfo = Array<{+name?: string, +env?: string}>;
112
+
113
const ObjectPrototype = Object.prototype;
114
115
type JSONValue =
@@ -199,6 +202,8 @@ export type Request = {
202
taintCleanupQueue: Array<string | bigint>,
203
onError: (error: mixed) => ?string,
204
onPostpone: (reason: string) => void,
205
+ // DEV-only
206
+ environmentName: string,
207
};
208
209
const {
@@ -251,6 +256,7 @@ export function createRequest(
256
onError: void | ((error: mixed) => ?string),
257
identifierPrefix?: string,
258
onPostpone: void | ((reason: string) => void),
259
+ environmentName: void | string,
260
): Request {
261
if (
262
ReactCurrentCache.current !== null &&
@@ -270,7 +276,7 @@ export function createRequest(
276
TaintRegistryPendingRequests.add(cleanupQueue);
277
}
278
const hints = createHints();
273
- const request: Request = {
279
+ const request: Request = ({
280
status: OPEN,
281
flushScheduled: false,
282
fatalError: null,
@@ -295,7 +301,11 @@ export function createRequest(
301
taintCleanupQueue: cleanupQueue,
302
onError: onError === undefined ? defaultErrorHandler : onError,
303
onPostpone: onPostpone === undefined ? defaultPostponeHandler : onPostpone,
298
- };
304
+ }: any);
305
+ if (__DEV__) {
306
+ request.environmentName =
307
+ environmentName === undefined ? 'server' : environmentName;
308
+ }
309
const rootTask = createTask(request, model, null, false, abortSet);
310
pingedTasks.push(rootTask);
311
return request;
@@ -325,6 +335,14 @@ function serializeThenable(
335
request.abortableTasks,
336
);
337
338
+ if (__DEV__) {
339
+ // If this came from Flight, forward any debug info into this new row.
340
+ const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo;
341
+ if (debugInfo) {
342
+ forwardDebugInfo(request, newTask.id, debugInfo);
343
+ }
344
+ }
345
+
346
switch (thenable.status) {
347
case 'fulfilled': {
348
// We have the resolved value, we can go ahead and schedule it for serialization.
@@ -475,6 +493,10 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) {
493
_payload: thenable,
494
_init: readThenable,
495
};
496
+ if (__DEV__) {
497
+ // If this came from React, transfer the debug info.
498
+ lazyType._debugInfo = (thenable: any)._debugInfo || [];
499
+ }
500
return lazyType;
501
}
502
@@ -504,7 +526,10 @@ function renderFunctionComponent<Props>(
526
const componentName =
527
(Component: any).displayName || Component.name || '';
528
request.pendingChunks++;
507
- emitDebugChunk(request, debugID, {name: componentName});
529
+ emitDebugChunk(request, debugID, {
530
+ name: componentName,
531
+ env: request.environmentName,
532
+ });
533
}
534
}
535
@@ -552,6 +577,22 @@ function renderFragment(
577
task: Task,
578
children: $ReadOnlyArray<ReactClientValue>,
579
): ReactJSONValue {
580
+ if (__DEV__) {
581
+ const debugInfo: ?ReactDebugInfo = (children: any)._debugInfo;
582
+ if (debugInfo) {
583
+ // If this came from Flight, forward any debug info into this new row.
584
+ if (debugID === null) {
585
+ // We don't have a chunk to assign debug info. We need to outline this
586
+ // component to assign it an ID.
587
+ return outlineTask(request, task);
588
+ } else {
589
+ // Forward any debug info we have the first time we see it.
590
+ // We do this after init so that we have received all the debug info
591
+ // from the server by the time we emit it.
592
+ forwardDebugInfo(request, debugID, debugInfo);
593
+ }
594
+ }
595
+ }
596
if (!enableServerComponentKeys) {
597
return children;
598
}
@@ -1210,6 +1251,22 @@ function renderModelDestructive(
1251
}
1252
1253
const element: React$Element<any> = (value: any);
1254
+
1255
+ if (__DEV__) {
1256
+ const debugInfo: ?ReactDebugInfo = (value: any)._debugInfo;
1257
+ if (debugInfo) {
1258
+ // If this came from Flight, forward any debug info into this new row.
1259
+ if (debugID === null) {
1260
+ // We don't have a chunk to assign debug info. We need to outline this
1261
+ // component to assign it an ID.
1262
+ return outlineTask(request, task);
1263
+ } else {
1264
+ // Forward any debug info we have the first time we see it.
1265
+ forwardDebugInfo(request, debugID, debugInfo);
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
// Attempt to render the Server Component.
1271
return renderElement(
1272
request,
@@ -1222,9 +1279,30 @@ function renderModelDestructive(
1279
);
1280
}
1281
case REACT_LAZY_TYPE: {
1225
- const payload = (value: any)._payload;
1226
- const init = (value: any)._init;
1282
+ // Reset the task's thenable state before continuing. If there was one, it was
1283
+ // from suspending the lazy before.
1284
+ task.thenableState = null;
1285
+
1286
+ const lazy: LazyComponent<any, any> = (value: any);
1287
+ const payload = lazy._payload;
1288
+ const init = lazy._init;
1289
const resolvedModel = init(payload);
1290
+ if (__DEV__) {
1291
+ const debugInfo: ?ReactDebugInfo = lazy._debugInfo;
1292
+ if (debugInfo) {
1293
+ // If this came from Flight, forward any debug info into this new row.
1294
+ if (debugID === null) {
1295
+ // We don't have a chunk to assign debug info. We need to outline this
1296
+ // component to assign it an ID.
1297
+ return outlineTask(request, task);
1298
+ } else {
1299
+ // Forward any debug info we have the first time we see it.
1300
+ // We do this after init so that we have received all the debug info
1301
+ // from the server by the time we emit it.
1302
+ forwardDebugInfo(request, debugID, debugInfo);
1303
+ }
1304
+ }
1305
+ }
1306
return renderModelDestructive(
1307
request,
1308
task,
@@ -1653,7 +1731,7 @@ function emitModelChunk(request: Request, id: number, json: string): void {
1731
function emitDebugChunk(
1732
request: Request,
1733
id: number,
1656
- debugInfo: {name: string},
1734
+ debugInfo: {+name?: string, +env?: string},
1735
): void {
1736
if (!__DEV__) {
1737
// These errors should never make it into a build so we don't need to encode them in codes.json
@@ -1669,6 +1747,17 @@ function emitDebugChunk(
1747
request.completedRegularChunks.push(processedChunk);
1748
}
1749
1750
+function forwardDebugInfo(
1751
+ request: Request,
1752
+ id: number,
1753
+ debugInfo: ReactDebugInfo,
1754
+) {
1755
+ for (let i = 0; i < debugInfo.length; i++) {
1756
+ request.pendingChunks++;
1757
+ emitDebugChunk(request, id, debugInfo[i]);
1758
+ }
1759
+}
1760
+
1761
const emptyRoot = {};
1762
1763
function retryTask(request: Request, task: Task): void {
packages/react/src/ReactLazy.js
+1
-1
@@ -46,7 +46,7 @@ export type LazyComponent<T, P> = {
46
$$typeof: symbol | number,
47
_payload: P,
48
_init: (payload: P) => T,
49
- _debugInfo?: null | Array<{+name?: string}>,
49
+ _debugInfo?: null | Array<{+name?: string, +env?: string}>,
50
};
51
52
function lazyInitializer<T>(payload: Payload<T>): T {
packages/react/src/__tests__/ReactFetch-test.js
+1
-1
@@ -85,7 +85,7 @@ describe('ReactFetch', () => {
85
const promise = render(Component);
86
expect(await promise).toMatchInlineSnapshot(`"GET world []"`);
87
expect(promise._debugInfo).toEqual(
88
- __DEV__ ? [{name: 'Component'}] : undefined,
88
+ __DEV__ ? [{name: 'Component', env: 'server'}] : undefined,
89
);
90
expect(fetchCount).toBe(1);
91
});