@samitouri / QOS-React-1 / commits / 629541bcc0

[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 });