From 2a299a63f2adb0acb09c36e9e3c8571458fe8614 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 6 Feb 2024 15:55:06 -0500 Subject: [PATCH] Emit debug info for a Server Component --- .../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." }