From 629541bcc09fc7c0cc5c257541d084ee27457512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 12 Feb 2024 13:38:14 -0500 Subject: [PATCH] [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. --- .../react-client/src/ReactFlightClient.js | 2 +- .../src/__tests__/ReactFlight-test.js | 68 +++++++++++- .../src/ReactNoopFlightServer.js | 6 +- .../src/ReactFlightDOMServerNode.js | 2 + .../src/ReactFlightDOMServerFB.js | 2 + .../src/ReactFlightDOMServerBrowser.js | 2 + .../src/ReactFlightDOMServerEdge.js | 2 + .../src/ReactFlightDOMServerNode.js | 2 + .../src/ReactFlightDOMServerBrowser.js | 2 + .../src/ReactFlightDOMServerEdge.js | 2 + .../src/ReactFlightDOMServerNode.js | 2 + .../src/__tests__/ReactFlightDOMEdge-test.js | 2 +- .../react-server/src/ReactFlightServer.js | 101 ++++++++++++++++-- packages/react/src/ReactLazy.js | 2 +- .../react/src/__tests__/ReactFetch-test.js | 2 +- 15 files changed, 186 insertions(+), 13 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 9579c3691e52e..46e09b1cf0274 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -77,7 +77,7 @@ const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; // Dev-only -type ReactDebugInfo = Array<{+name?: string}>; +type ReactDebugInfo = Array<{+name?: string, +env?: string}>; type PendingChunk = { status: 'pending', diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 91991a72e863b..52c127397e6d8 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -187,7 +187,7 @@ describe('ReactFlight', () => { const rootModel = await ReactNoopFlightClient.read(transport); const greeting = rootModel.greeting; expect(greeting._debugInfo).toEqual( - __DEV__ ? [{name: 'Greeting'}] : undefined, + __DEV__ ? [{name: 'Greeting', env: 'server'}] : undefined, ); ReactNoop.render(greeting); }); @@ -214,7 +214,7 @@ describe('ReactFlight', () => { await act(async () => { const promise = ReactNoopFlightClient.read(transport); expect(promise._debugInfo).toEqual( - __DEV__ ? [{name: 'Greeting'}] : undefined, + __DEV__ ? [{name: 'Greeting', env: 'server'}] : undefined, ); ReactNoop.render(await promise); }); @@ -1806,4 +1806,68 @@ describe('ReactFlight', () => { expect(ReactNoop).toMatchRenderedOutput(
Ba
); }); + + it('preserves debug info for server-to-server pass through', async () => { + function ThirdPartyLazyComponent() { + return !; + } + + const lazy = React.lazy(async () => ({ + default: , + })); + + function ThirdPartyComponent() { + return stranger; + } + + function ServerComponent({transport}) { + // This is a Server Component that receives other Server Components from a third party. + const children = ReactNoopFlightClient.read(transport); + return
Hello, {children}
; + } + + const promiseComponent = Promise.resolve(); + + const thirdPartyTransport = ReactNoopFlightServer.render( + [promiseComponent, lazy], + { + environmentName: 'third-party', + }, + ); + + // Wait for the lazy component to initialize + await 0; + + const transport = ReactNoopFlightServer.render( + , + ); + + await act(async () => { + const promise = ReactNoopFlightClient.read(transport); + expect(promise._debugInfo).toEqual( + __DEV__ ? [{name: 'ServerComponent', env: 'server'}] : undefined, + ); + const result = await promise; + const thirdPartyChildren = await result.props.children[1]; + // We expect the debug info to be transferred from the inner stream to the outer. + expect(thirdPartyChildren[0]._debugInfo).toEqual( + __DEV__ + ? [{name: 'ThirdPartyComponent', env: 'third-party'}] + : undefined, + ); + expect(thirdPartyChildren[1]._debugInfo).toEqual( + __DEV__ + ? [{name: 'ThirdPartyLazyComponent', env: 'third-party'}] + : undefined, + ); + ReactNoop.render(result); + }); + + expect(ReactNoop).toMatchRenderedOutput( +
+ Hello, stranger + ! +
, + ); + }); }); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 41bdcd3d6b0d4..3d46f7694c798 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -68,8 +68,10 @@ const ReactNoopFlightServer = ReactFlightServer({ }); type Options = { - onError?: (error: mixed) => void, + environmentName?: string, identifierPrefix?: string, + onError?: (error: mixed) => void, + onPostpone?: (reason: string) => void, }; function render(model: ReactClientValue, options?: Options): Destination { @@ -80,6 +82,8 @@ function render(model: ReactClientValue, options?: Options): Destination { bundlerConfig, options ? options.onError : undefined, options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); ReactNoopFlightServer.startWork(request); ReactNoopFlightServer.startFlowing(request, destination); diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js index 08c2ede7f8f9c..df14751b74342 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js @@ -52,6 +52,7 @@ function createDrainHandler(destination: Destination, request: Request) { } type Options = { + environmentName?: string, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, identifierPrefix?: string, @@ -73,6 +74,7 @@ function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); let hasStartedFlowing = false; startWork(request); diff --git a/packages/react-server-dom-fb/src/ReactFlightDOMServerFB.js b/packages/react-server-dom-fb/src/ReactFlightDOMServerFB.js index fdfa5a008f659..5fc7f11e7cb88 100644 --- a/packages/react-server-dom-fb/src/ReactFlightDOMServerFB.js +++ b/packages/react-server-dom-fb/src/ReactFlightDOMServerFB.js @@ -50,6 +50,8 @@ function renderToDestination( model, null, options ? options.onError : undefined, + undefined, + undefined, ); startWork(request); startFlowing(request, destination); diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js index ed8407b312749..da9094cc5212d 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js @@ -34,6 +34,7 @@ export { } from './ReactFlightTurbopackReferences'; type Options = { + environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, onError?: (error: mixed) => void, @@ -51,6 +52,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js index ed8407b312749..da9094cc5212d 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js @@ -34,6 +34,7 @@ export { } from './ReactFlightTurbopackReferences'; type Options = { + environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, onError?: (error: mixed) => void, @@ -51,6 +52,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js index a988b79fad885..5be4c4546544a 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js @@ -49,6 +49,7 @@ function createDrainHandler(destination: Destination, request: Request) { } type Options = { + environmentName?: string, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, identifierPrefix?: string, @@ -70,6 +71,7 @@ function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); let hasStartedFlowing = false; startWork(request); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js index 2c5c5bcebc500..2e389abd5a041 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js @@ -38,6 +38,7 @@ export { } from './ReactFlightWebpackReferences'; type Options = { + environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, onError?: (error: mixed) => void, @@ -55,6 +56,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js index 2c5c5bcebc500..2e389abd5a041 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -38,6 +38,7 @@ export { } from './ReactFlightWebpackReferences'; type Options = { + environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, onError?: (error: mixed) => void, @@ -55,6 +56,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index 850511ab099ef..7546e1eac6612 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -61,6 +61,7 @@ function createCancelHandler(request: Request, reason: string) { } type Options = { + environmentName?: string, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, identifierPrefix?: string, @@ -82,6 +83,7 @@ function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, + options ? options.environmentName : undefined, ); let hasStartedFlowing = false; startWork(request); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 2eaf6b30a7506..eb2298197dfbb 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -286,7 +286,7 @@ describe('ReactFlightDOMEdge', () => { , ); const serializedContent = await readResult(stream); - const expectedDebugInfoSize = __DEV__ ? 30 * 20 : 0; + const expectedDebugInfoSize = __DEV__ ? 42 * 20 : 0; expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 7f0ad956cf5be..e0d26b2d30473 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -107,6 +107,9 @@ import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable'; initAsyncDebugInfo(); +// Dev-only +type ReactDebugInfo = Array<{+name?: string, +env?: string}>; + const ObjectPrototype = Object.prototype; type JSONValue = @@ -199,6 +202,8 @@ export type Request = { taintCleanupQueue: Array, onError: (error: mixed) => ?string, onPostpone: (reason: string) => void, + // DEV-only + environmentName: string, }; const { @@ -251,6 +256,7 @@ export function createRequest( onError: void | ((error: mixed) => ?string), identifierPrefix?: string, onPostpone: void | ((reason: string) => void), + environmentName: void | string, ): Request { if ( ReactCurrentCache.current !== null && @@ -270,7 +276,7 @@ export function createRequest( TaintRegistryPendingRequests.add(cleanupQueue); } const hints = createHints(); - const request: Request = { + const request: Request = ({ status: OPEN, flushScheduled: false, fatalError: null, @@ -295,7 +301,11 @@ export function createRequest( taintCleanupQueue: cleanupQueue, onError: onError === undefined ? defaultErrorHandler : onError, onPostpone: onPostpone === undefined ? defaultPostponeHandler : onPostpone, - }; + }: any); + if (__DEV__) { + request.environmentName = + environmentName === undefined ? 'server' : environmentName; + } const rootTask = createTask(request, model, null, false, abortSet); pingedTasks.push(rootTask); return request; @@ -325,6 +335,14 @@ function serializeThenable( request.abortableTasks, ); + if (__DEV__) { + // If this came from Flight, forward any debug info into this new row. + const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; + if (debugInfo) { + forwardDebugInfo(request, newTask.id, debugInfo); + } + } + switch (thenable.status) { case 'fulfilled': { // We have the resolved value, we can go ahead and schedule it for serialization. @@ -475,6 +493,10 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) { _payload: thenable, _init: readThenable, }; + if (__DEV__) { + // If this came from React, transfer the debug info. + lazyType._debugInfo = (thenable: any)._debugInfo || []; + } return lazyType; } @@ -504,7 +526,10 @@ function renderFunctionComponent( const componentName = (Component: any).displayName || Component.name || ''; request.pendingChunks++; - emitDebugChunk(request, debugID, {name: componentName}); + emitDebugChunk(request, debugID, { + name: componentName, + env: request.environmentName, + }); } } @@ -552,6 +577,22 @@ function renderFragment( task: Task, children: $ReadOnlyArray, ): ReactJSONValue { + if (__DEV__) { + const debugInfo: ?ReactDebugInfo = (children: any)._debugInfo; + if (debugInfo) { + // If this came from Flight, forward any debug info into this new row. + if (debugID === null) { + // We don't have a chunk to assign debug info. We need to outline this + // component to assign it an ID. + return outlineTask(request, task); + } else { + // Forward any debug info we have the first time we see it. + // We do this after init so that we have received all the debug info + // from the server by the time we emit it. + forwardDebugInfo(request, debugID, debugInfo); + } + } + } if (!enableServerComponentKeys) { return children; } @@ -1210,6 +1251,22 @@ function renderModelDestructive( } const element: React$Element = (value: any); + + if (__DEV__) { + const debugInfo: ?ReactDebugInfo = (value: any)._debugInfo; + if (debugInfo) { + // If this came from Flight, forward any debug info into this new row. + if (debugID === null) { + // We don't have a chunk to assign debug info. We need to outline this + // component to assign it an ID. + return outlineTask(request, task); + } else { + // Forward any debug info we have the first time we see it. + forwardDebugInfo(request, debugID, debugInfo); + } + } + } + // Attempt to render the Server Component. return renderElement( request, @@ -1222,9 +1279,30 @@ function renderModelDestructive( ); } case REACT_LAZY_TYPE: { - const payload = (value: any)._payload; - const init = (value: any)._init; + // Reset the task's thenable state before continuing. If there was one, it was + // from suspending the lazy before. + task.thenableState = null; + + const lazy: LazyComponent = (value: any); + const payload = lazy._payload; + const init = lazy._init; const resolvedModel = init(payload); + if (__DEV__) { + const debugInfo: ?ReactDebugInfo = lazy._debugInfo; + if (debugInfo) { + // If this came from Flight, forward any debug info into this new row. + if (debugID === null) { + // We don't have a chunk to assign debug info. We need to outline this + // component to assign it an ID. + return outlineTask(request, task); + } else { + // Forward any debug info we have the first time we see it. + // We do this after init so that we have received all the debug info + // from the server by the time we emit it. + forwardDebugInfo(request, debugID, debugInfo); + } + } + } return renderModelDestructive( request, task, @@ -1653,7 +1731,7 @@ function emitModelChunk(request: Request, id: number, json: string): void { function emitDebugChunk( request: Request, id: number, - debugInfo: {name: string}, + debugInfo: {+name?: string, +env?: string}, ): void { if (!__DEV__) { // 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( request.completedRegularChunks.push(processedChunk); } +function forwardDebugInfo( + request: Request, + id: number, + debugInfo: ReactDebugInfo, +) { + for (let i = 0; i < debugInfo.length; i++) { + request.pendingChunks++; + emitDebugChunk(request, id, debugInfo[i]); + } +} + const emptyRoot = {}; function retryTask(request: Request, task: Task): void { diff --git a/packages/react/src/ReactLazy.js b/packages/react/src/ReactLazy.js index 7c219638408e6..ece18edca40a3 100644 --- a/packages/react/src/ReactLazy.js +++ b/packages/react/src/ReactLazy.js @@ -46,7 +46,7 @@ export type LazyComponent = { $$typeof: symbol | number, _payload: P, _init: (payload: P) => T, - _debugInfo?: null | Array<{+name?: string}>, + _debugInfo?: null | Array<{+name?: string, +env?: string}>, }; function lazyInitializer(payload: Payload): T { diff --git a/packages/react/src/__tests__/ReactFetch-test.js b/packages/react/src/__tests__/ReactFetch-test.js index 9bb4d89777221..f89cce08fc138 100644 --- a/packages/react/src/__tests__/ReactFetch-test.js +++ b/packages/react/src/__tests__/ReactFetch-test.js @@ -85,7 +85,7 @@ describe('ReactFetch', () => { const promise = render(Component); expect(await promise).toMatchInlineSnapshot(`"GET world []"`); expect(promise._debugInfo).toEqual( - __DEV__ ? [{name: 'Component'}] : undefined, + __DEV__ ? [{name: 'Component', env: 'server'}] : undefined, ); expect(fetchCount).toBe(1); });