From ec33294ea6ffeda4cd99e9ad2b32e2ff5a37879b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 7 Feb 2024 15:53:27 -0500 Subject: [PATCH 1/2] Transfer debug info for server-to-server request 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. --- .../src/__tests__/ReactFlight-test.js | 55 ++++++++++++ .../react-server/src/ReactFlightServer.js | 85 ++++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 91991a72e863b..940e298b22745 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -1806,4 +1806,59 @@ 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 promise = Promise.resolve(); + + const thirdPartyTransport = ReactNoopFlightServer.render([promise, lazy]); + + // 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'}] : 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'}] : undefined, + ); + expect(thirdPartyChildren[1]._debugInfo).toEqual( + __DEV__ ? [{name: 'ThirdPartyLazyComponent'}] : undefined, + ); + ReactNoop.render(result); + }); + + expect(ReactNoop).toMatchRenderedOutput( +
+ Hello, stranger + ! +
, + ); + }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index b1b6c73e46780..bb0d1e954dfcf 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}>; + const ObjectPrototype = Object.prototype; type JSONValue = @@ -325,6 +328,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 +486,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; } @@ -552,6 +567,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; } @@ -1206,6 +1237,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, @@ -1218,9 +1265,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, @@ -1649,7 +1717,7 @@ function emitModelChunk(request: Request, id: number, json: string): void { function emitDebugChunk( request: Request, id: number, - debugInfo: {name: string}, + debugInfo: {+name?: string}, ): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json @@ -1665,6 +1733,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 { From c4c5e0faf2bcb6f6bd6d78d57bd3f8e7c4402abc Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 7 Feb 2024 20:18:31 -0500 Subject: [PATCH 2/2] Add environmentName option 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. --- .../react-client/src/ReactFlightClient.js | 2 +- .../src/__tests__/ReactFlight-test.js | 23 +++++++++++++------ .../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 | 20 ++++++++++++---- packages/react/src/ReactLazy.js | 2 +- .../react/src/__tests__/ReactFetch-test.js | 2 +- 15 files changed, 56 insertions(+), 17 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 940e298b22745..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); }); @@ -1826,9 +1826,14 @@ describe('ReactFlight', () => { return
Hello, {children}
; } - const promise = Promise.resolve(); + const promiseComponent = Promise.resolve(); - const thirdPartyTransport = ReactNoopFlightServer.render([promise, lazy]); + const thirdPartyTransport = ReactNoopFlightServer.render( + [promiseComponent, lazy], + { + environmentName: 'third-party', + }, + ); // Wait for the lazy component to initialize await 0; @@ -1840,16 +1845,20 @@ describe('ReactFlight', () => { await act(async () => { const promise = ReactNoopFlightClient.read(transport); expect(promise._debugInfo).toEqual( - __DEV__ ? [{name: 'ServerComponent'}] : undefined, + __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'}] : undefined, + __DEV__ + ? [{name: 'ThirdPartyComponent', env: 'third-party'}] + : undefined, ); expect(thirdPartyChildren[1]._debugInfo).toEqual( - __DEV__ ? [{name: 'ThirdPartyLazyComponent'}] : undefined, + __DEV__ + ? [{name: 'ThirdPartyLazyComponent', env: 'third-party'}] + : undefined, ); ReactNoop.render(result); }); 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 bb0d1e954dfcf..0350b1a6abf3a 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -108,7 +108,7 @@ import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable'; initAsyncDebugInfo(); // Dev-only -type ReactDebugInfo = Array<{+name?: string}>; +type ReactDebugInfo = Array<{+name?: string, +env?: string}>; const ObjectPrototype = Object.prototype; @@ -202,6 +202,8 @@ export type Request = { taintCleanupQueue: Array, onError: (error: mixed) => ?string, onPostpone: (reason: string) => void, + // DEV-only + environmentName: string, }; const { @@ -254,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 && @@ -273,7 +276,7 @@ export function createRequest( TaintRegistryPendingRequests.add(cleanupQueue); } const hints = createHints(); - const request: Request = { + const request: Request = ({ status: OPEN, flushScheduled: false, fatalError: null, @@ -298,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; @@ -519,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, + }); } } @@ -1717,7 +1727,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 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); });