From b229f540e2da91370611945f9875e00a96196df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 8 Feb 2024 08:01:32 -0800 Subject: [PATCH] [Flight] Emit debug info for a Server Component (#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. --- .../react-client/src/ReactFlightClient.js | 88 ++++++++++++++++++- .../src/__tests__/ReactFlight-test.js | 30 +++++++ .../src/__tests__/ReactFlightDOMEdge-test.js | 3 +- packages/react-server/src/ReactFlightHooks.js | 7 +- .../react-server/src/ReactFlightServer.js | 80 ++++++++++++++++- packages/react/src/ReactElementProd.js | 7 ++ packages/react/src/ReactLazy.js | 1 + .../react/src/__tests__/ReactFetch-test.js | 8 +- packages/react/src/jsx/ReactJSXElement.js | 7 ++ scripts/error-codes/codes.json | 3 +- 10 files changed, 223 insertions(+), 11 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 20ff8abe4a08f..9579c3691e52e 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -76,11 +76,15 @@ const RESOLVED_MODULE = 'resolved_module'; const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; +// Dev-only +type ReactDebugInfo = Array<{+name?: string}>; + type PendingChunk = { status: 'pending', value: null | Array<(T) => mixed>, reason: null | Array<(mixed) => mixed>, _response: Response, + _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; type BlockedChunk = { @@ -88,6 +92,7 @@ type BlockedChunk = { value: null | Array<(T) => mixed>, reason: null | Array<(mixed) => mixed>, _response: Response, + _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; type CyclicChunk = { @@ -95,6 +100,7 @@ type CyclicChunk = { value: null | Array<(T) => mixed>, reason: null | Array<(mixed) => mixed>, _response: Response, + _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; type ResolvedModelChunk = { @@ -102,6 +108,7 @@ type ResolvedModelChunk = { value: UninitializedModel, reason: null, _response: Response, + _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; type ResolvedModuleChunk = { @@ -109,6 +116,7 @@ type ResolvedModuleChunk = { value: ClientReference, reason: null, _response: Response, + _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; type InitializedChunk = { @@ -116,6 +124,7 @@ type InitializedChunk = { value: T, reason: null, _response: Response, + _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; type ErroredChunk = { @@ -123,6 +132,7 @@ type ErroredChunk = { value: null, reason: mixed, _response: Response, + _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; type SomeChunk = @@ -140,6 +150,9 @@ function Chunk(status: any, value: any, reason: any, response: Response) { this.value = value; this.reason = reason; this._response = response; + if (__DEV__) { + this._debugInfo = null; + } } // We subclass Promise.prototype so that we get other methods like .catch Chunk.prototype = (Object.create(Promise.prototype): any); @@ -475,6 +488,13 @@ function createElement( writable: true, value: true, // This element has already been validated on the server. }); + // debugInfo contains Server Component debug information. + Object.defineProperty(element, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: null, + }); } return element; } @@ -487,6 +507,12 @@ function createLazyChunkWrapper( _payload: chunk, _init: readChunk, }; + if (__DEV__) { + // Ensure we have a live array to track future debug info. + const chunkDebugInfo: ReactDebugInfo = + chunk._debugInfo || (chunk._debugInfo = []); + lazyType._debugInfo = chunkDebugInfo; + } return lazyType; } @@ -682,7 +708,33 @@ function parseModelString( // The status might have changed after initialization. switch (chunk.status) { case INITIALIZED: - return chunk.value; + const chunkValue = chunk.value; + if (__DEV__ && chunk._debugInfo) { + // If we have a direct reference to an object that was rendered by a synchronous + // server component, it might have some debug info about how it was rendered. + // We forward this to the underlying object. This might be a React Element or + // an Array fragment. + // If this was a string / number return value we lose the debug info. We choose + // that tradeoff to allow sync server components to return plain values and not + // use them as React Nodes necessarily. We could otherwise wrap them in a Lazy. + if ( + typeof chunkValue === 'object' && + chunkValue !== null && + (Array.isArray(chunkValue) || + chunkValue.$$typeof === REACT_ELEMENT_TYPE) && + !chunkValue._debugInfo + ) { + // We should maybe use a unique symbol for arrays but this is a React owned array. + // $FlowFixMe[prop-missing]: This should be added to elements. + Object.defineProperty(chunkValue, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: chunk._debugInfo, + }); + } + } + return chunkValue; case PENDING: case BLOCKED: case CYCLIC: @@ -959,6 +1011,24 @@ function resolveHint( dispatchHint(code, hintModel); } +function resolveDebugInfo( + response: Response, + id: number, + 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 + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'resolveDebugInfo should never be called in production mode. This is a bug in React.', + ); + } + const chunk = getChunk(response, id); + const chunkDebugInfo: ReactDebugInfo = + chunk._debugInfo || (chunk._debugInfo = []); + chunkDebugInfo.push(debugInfo); +} + function mergeBuffer( buffer: Array, lastChunk: Uint8Array, @@ -1052,7 +1122,7 @@ function processFullRow( case 70 /* "F" */: resolveTypedArray(response, id, buffer, chunk, Float32Array, 4); return; - case 68 /* "D" */: + case 100 /* "d" */: resolveTypedArray(response, id, buffer, chunk, Float64Array, 8); return; case 78 /* "N" */: @@ -1102,6 +1172,18 @@ function processFullRow( resolveText(response, id, row); return; } + case 68 /* "D" */: { + if (__DEV__) { + const debugInfo = JSON.parse(row); + resolveDebugInfo(response, id, debugInfo); + return; + } + throw new Error( + 'Failed to read a RSC payload created by a development version of React ' + + 'on the server while using a production version on the client. Always use ' + + 'matching versions on the server and the client.', + ); + } case 80 /* "P" */: { if (enablePostpone) { if (__DEV__) { @@ -1165,7 +1247,7 @@ export function processBinaryChunk( resolvedRowTag === 76 /* "L" */ || resolvedRowTag === 108 /* "l" */ || resolvedRowTag === 70 /* "F" */ || - resolvedRowTag === 68 /* "D" */ || + resolvedRowTag === 100 /* "d" */ || resolvedRowTag === 78 /* "N" */ || resolvedRowTag === 109 /* "m" */ || resolvedRowTag === 86)) /* "V" */ diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index eb1b16e7f22ab..91991a72e863b 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -186,12 +186,42 @@ describe('ReactFlight', () => { await act(async () => { const rootModel = await ReactNoopFlightClient.read(transport); const greeting = rootModel.greeting; + expect(greeting._debugInfo).toEqual( + __DEV__ ? [{name: 'Greeting'}] : undefined, + ); ReactNoop.render(greeting); }); expect(ReactNoop).toMatchRenderedOutput(Hello, Seb Smith); }); + it('can render a shared forwardRef Component', async () => { + const Greeting = React.forwardRef(function Greeting( + {firstName, lastName}, + ref, + ) { + return ( + + Hello, {firstName} {lastName} + + ); + }); + + const root = ; + + const transport = ReactNoopFlightServer.render(root); + + await act(async () => { + const promise = ReactNoopFlightClient.read(transport); + expect(promise._debugInfo).toEqual( + __DEV__ ? [{name: 'Greeting'}] : undefined, + ); + ReactNoop.render(await promise); + }); + + expect(ReactNoop).toMatchRenderedOutput(Hello, Seb Smith); + }); + it('can render an iterable as an array', async () => { function ItemListClient(props) { return {props.items}; 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 2ab419c780aa4..2eaf6b30a7506 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,8 @@ describe('ReactFlightDOMEdge', () => { , ); const serializedContent = await readResult(stream); - expect(serializedContent.length).toBeLessThan(150); + const expectedDebugInfoSize = __DEV__ ? 30 * 20 : 0; + expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize); }); // @gate enableBinaryFlight diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index d8223db133db5..75a99dc558ea5 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -37,8 +37,11 @@ export function prepareToUseHooksForComponent( thenableState = prevThenableState; } -export function getThenableStateAfterSuspending(): null | ThenableState { - const state = thenableState; +export function getThenableStateAfterSuspending(): ThenableState { + // If you use() to Suspend this should always exist but if you throw a Promise instead, + // which is not really supported anymore, it will be empty. We use the empty set as a + // marker to know if this was a replay of the same component or first attempt. + const state = thenableState || createThenableState(); thenableState = null; return state; } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 8d549b6ade5d2..b1b6c73e46780 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -491,6 +491,23 @@ function renderFunctionComponent( const prevThenableState = task.thenableState; task.thenableState = null; + if (__DEV__) { + 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 if (prevThenableState !== null) { + // This is a replay and we've already emitted the debug info of this component + // in the first pass. We skip emitting a duplicate line. + } else { + // This is a new component in the same task so we can emit more debug info. + const componentName = + (Component: any).displayName || Component.name || ''; + request.pendingChunks++; + emitDebugChunk(request, debugID, {name: componentName}); + } + } + prepareToUseHooksForComponent(prevThenableState); // The secondArg is always undefined in Server Components since refs error early. const secondArg = undefined; @@ -605,6 +622,29 @@ function renderClientElement( return element; } +// The chunk ID we're currently rendering that we can assign debug data to. +let debugID: null | number = null; + +function outlineTask(request: Request, task: Task): ReactJSONValue { + const newTask = createTask( + request, + task.model, // the currently rendering element + task.keyPath, // unlike outlineModel this one carries along context + task.implicitSlot, + request.abortableTasks, + ); + + retryTask(request, newTask); + if (newTask.status === COMPLETED) { + // We completed synchronously so we can refer to this by reference. This + // makes it behaves the same as prod during deserialization. + return serializeByValueID(newTask.id); + } + // This didn't complete synchronously so it wouldn't have even if we didn't + // outline it, so this would reduce to a lazy reference even in prod. + return serializeLazyID(newTask.id); +} + function renderElement( request: Request, task: Task, @@ -632,7 +672,7 @@ function renderElement( // This is a reference to a Client Component. return renderClientElement(task, type, key, props); } - // This is a server-side component. + // This is a Server Component. return renderFunctionComponent(request, task, key, type, props); } else if (typeof type === 'string') { // This is a host element. E.g. HTML. @@ -1306,7 +1346,7 @@ function renderModelDestructive( } if (value instanceof Float64Array) { // double - return serializeTypedArray(request, 'D', value); + return serializeTypedArray(request, 'd', value); } if (value instanceof BigInt64Array) { // number @@ -1606,6 +1646,25 @@ function emitModelChunk(request: Request, id: number, json: string): void { request.completedRegularChunks.push(processedChunk); } +function emitDebugChunk( + request: Request, + id: number, + 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 + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'emitDebugChunk should never be called in production mode. This is a bug in React.', + ); + } + // $FlowFixMe[incompatible-type] stringify can return null + const json: string = stringify(debugInfo); + const row = serializeRowHeader('D', id) + json + '\n'; + const processedChunk = stringToChunk(row); + request.completedRegularChunks.push(processedChunk); +} + const emptyRoot = {}; function retryTask(request: Request, task: Task): void { @@ -1614,12 +1673,19 @@ function retryTask(request: Request, task: Task): void { return; } + const prevDebugID = debugID; + try { // Track the root so we know that we have to emit this object even though it // already has an ID. This is needed because we might see this object twice // in the same toJSON if it is cyclic. modelRoot = task.model; + if (__DEV__) { + // Track the ID of the current task so we can assign debug info to this id. + debugID = task.id; + } + // We call the destructive form that mutates this task. That way if something // suspends again, we can reuse the same task instead of spawning a new one. const resolvedModel = renderModelDestructive( @@ -1630,6 +1696,12 @@ function retryTask(request: Request, task: Task): void { task.model, ); + if (__DEV__) { + // We're now past rendering this task and future renders will spawn new tasks for their + // debug info. + debugID = null; + } + // Track the root again for the resolved object. modelRoot = resolvedModel; @@ -1684,6 +1756,10 @@ function retryTask(request: Request, task: Task): void { task.status = ERRORED; const digest = logRecoverableError(request, x); emitErrorChunk(request, task.id, digest, x); + } finally { + if (__DEV__) { + debugID = prevDebugID; + } } } diff --git a/packages/react/src/ReactElementProd.js b/packages/react/src/ReactElementProd.js index b81b84730431d..36c9381ef1ff4 100644 --- a/packages/react/src/ReactElementProd.js +++ b/packages/react/src/ReactElementProd.js @@ -170,6 +170,13 @@ function ReactElement(type, key, ref, owner, props) { writable: true, value: false, }); + // debugInfo contains Server Component debug information. + Object.defineProperty(element, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: null, + }); if (Object.freeze) { Object.freeze(element.props); Object.freeze(element); diff --git a/packages/react/src/ReactLazy.js b/packages/react/src/ReactLazy.js index 1debecda55578..7c219638408e6 100644 --- a/packages/react/src/ReactLazy.js +++ b/packages/react/src/ReactLazy.js @@ -46,6 +46,7 @@ export type LazyComponent = { $$typeof: symbol | number, _payload: P, _init: (payload: P) => T, + _debugInfo?: null | Array<{+name?: 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 5be624e2f8244..9bb4d89777221 100644 --- a/packages/react/src/__tests__/ReactFetch-test.js +++ b/packages/react/src/__tests__/ReactFetch-test.js @@ -60,7 +60,7 @@ describe('ReactFetch', () => { cache = ReactServer.cache; }); - async function render(Component) { + function render(Component) { const stream = ReactServerDOMServer.renderToReadableStream(); return ReactServerDOMClient.createFromReadableStream(stream); } @@ -82,7 +82,11 @@ describe('ReactFetch', () => { const text = use(response.text()); return text; } - expect(await render(Component)).toMatchInlineSnapshot(`"GET world []"`); + const promise = render(Component); + expect(await promise).toMatchInlineSnapshot(`"GET world []"`); + expect(promise._debugInfo).toEqual( + __DEV__ ? [{name: 'Component'}] : undefined, + ); expect(fetchCount).toBe(1); }); diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index e2ad636e035f3..b5fae759e0acc 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -170,6 +170,13 @@ function ReactElement(type, key, ref, self, source, owner, props) { writable: true, value: false, }); + // debugInfo contains Server Component debug information. + Object.defineProperty(element, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: null, + }); if (Object.freeze) { Object.freeze(element.props); Object.freeze(element); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 11451f2b62316..d02f0f6e1d4ec 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -488,5 +488,6 @@ "500": "React expected a headers state to exist when emitEarlyPreloads was called but did not find it. This suggests emitEarlyPreloads was called more than once per request. This is a bug in React.", "501": "The render was aborted with postpone when the shell is incomplete. Reason: %s", "502": "Cannot read a Client Context from a Server Component.", - "503": "Cannot use() an already resolved Client Reference." + "503": "Cannot use() an already resolved Client Reference.", + "504": "Failed to read a RSC payload created by a development version of React on the server while using a production version on the client. Always use matching versions on the server and the client." }