diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 6e96f2f0378ec..701d9df33cdd9 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -499,10 +499,44 @@ function createErrorChunk( return new ReactPromise(ERRORED, null, error); } +function moveDebugInfoFromChunkToInnerValue( + chunk: InitializedChunk, + value: T, +): void { + // Remove the debug info from the initialized chunk, and add it to the inner + // value instead. This can be a React element, an array, or an uninitialized + // Lazy. + const resolvedValue = resolveLazy(value); + if ( + typeof resolvedValue === 'object' && + resolvedValue !== null && + (isArray(resolvedValue) || + typeof resolvedValue[ASYNC_ITERATOR] === 'function' || + resolvedValue.$$typeof === REACT_ELEMENT_TYPE || + resolvedValue.$$typeof === REACT_LAZY_TYPE) + ) { + const debugInfo = chunk._debugInfo.splice(0); + if (isArray(resolvedValue._debugInfo)) { + // $FlowFixMe[method-unbinding] + resolvedValue._debugInfo.unshift.apply( + resolvedValue._debugInfo, + debugInfo, + ); + } else { + Object.defineProperty((resolvedValue: any), '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } +} + function wakeChunk( listeners: Array mixed)>, value: T, - chunk: SomeChunk, + chunk: InitializedChunk, ): void { for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; @@ -512,6 +546,10 @@ function wakeChunk( fulfillReference(listener, value, chunk); } } + + if (__DEV__) { + moveDebugInfoFromChunkToInnerValue(chunk, value); + } } function rejectChunk( @@ -649,7 +687,6 @@ function triggerErrorOnChunk( } try { initializeDebugChunk(response, chunk); - chunk._debugChunk = null; if (initializingHandler !== null) { if (initializingHandler.errored) { // Ignore error parsing debug info, we'll report the original error instead. @@ -932,9 +969,9 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } if (__DEV__) { - // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. + // Initialize any debug info and block the initializing chunk on any + // unresolved entries. initializeDebugChunk(response, chunk); - chunk._debugChunk = null; } try { @@ -946,7 +983,14 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { if (resolveListeners !== null) { cyclicChunk.value = null; cyclicChunk.reason = null; - wakeChunk(resolveListeners, value, cyclicChunk); + for (let i = 0; i < resolveListeners.length; i++) { + const listener = resolveListeners[i]; + if (typeof listener === 'function') { + listener(value); + } else { + fulfillReference(listener, value, cyclicChunk); + } + } } if (initializingHandler !== null) { if (initializingHandler.errored) { @@ -963,6 +1007,10 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = value; + + if (__DEV__) { + moveDebugInfoFromChunkToInnerValue(initializedChunk, value); + } } catch (error) { const erroredChunk: ErroredChunk = (chunk: any); erroredChunk.status = ERRORED; @@ -1079,7 +1127,7 @@ function getTaskName(type: mixed): string { function initializeElement( response: Response, element: any, - lazyType: null | LazyComponent< + lazyNode: null | LazyComponent< React$Element, SomeChunk>, >, @@ -1151,15 +1199,33 @@ function initializeElement( initializeFakeStack(response, owner); } - // In case the JSX runtime has validated the lazy type as a static child, we - // need to transfer this information to the element. - if ( - lazyType && - lazyType._store && - lazyType._store.validated && - !element._store.validated - ) { - element._store.validated = lazyType._store.validated; + if (lazyNode !== null) { + // In case the JSX runtime has validated the lazy type as a static child, we + // need to transfer this information to the element. + if ( + lazyNode._store && + lazyNode._store.validated && + !element._store.validated + ) { + element._store.validated = lazyNode._store.validated; + } + + // If the lazy node is initialized, we move its debug info to the inner + // value. + if (lazyNode._payload.status === INITIALIZED && lazyNode._debugInfo) { + const debugInfo = lazyNode._debugInfo.splice(0); + if (element._debugInfo) { + // $FlowFixMe[method-unbinding] + element._debugInfo.unshift.apply(element._debugInfo, debugInfo); + } else { + Object.defineProperty(element, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } } // TODO: We should be freezing the element but currently, we might write into @@ -1279,13 +1345,13 @@ function createElement( createBlockedChunk(response); handler.value = element; handler.chunk = blockedChunk; - const lazyType = createLazyChunkWrapper(blockedChunk, validated); + const lazyNode = createLazyChunkWrapper(blockedChunk, validated); if (__DEV__) { // After we have initialized any blocked references, initialize stack etc. - const init = initializeElement.bind(null, response, element, lazyType); + const init = initializeElement.bind(null, response, element, lazyNode); blockedChunk.then(init, init); } - return lazyType; + return lazyNode; } } if (__DEV__) { @@ -1466,7 +1532,7 @@ function fulfillReference( const element: any = handler.value; switch (key) { case '3': - transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue); + transferReferencedDebugInfo(handler.chunk, fulfilledChunk); element.props = mappedValue; break; case '4': @@ -1482,11 +1548,11 @@ function fulfillReference( } break; default: - transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue); + transferReferencedDebugInfo(handler.chunk, fulfilledChunk); break; } } else if (__DEV__ && !reference.isDebug) { - transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue); + transferReferencedDebugInfo(handler.chunk, fulfilledChunk); } handler.deps--; @@ -1808,47 +1874,34 @@ function loadServerReference, T>( return (null: any); } +function resolveLazy(value: any): mixed { + while ( + typeof value === 'object' && + value !== null && + value.$$typeof === REACT_LAZY_TYPE + ) { + const payload: SomeChunk = value._payload; + if (payload.status === INITIALIZED) { + value = payload.value; + continue; + } + break; + } + + return value; +} + function transferReferencedDebugInfo( parentChunk: null | SomeChunk, referencedChunk: SomeChunk, - referencedValue: mixed, ): void { if (__DEV__) { - const referencedDebugInfo = referencedChunk._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 referencedValue === 'object' && - referencedValue !== null && - (isArray(referencedValue) || - typeof referencedValue[ASYNC_ITERATOR] === 'function' || - referencedValue.$$typeof === REACT_ELEMENT_TYPE) - ) { - // 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. - const existingDebugInfo: ?ReactDebugInfo = - (referencedValue._debugInfo: any); - if (existingDebugInfo == null) { - Object.defineProperty((referencedValue: any), '_debugInfo', { - configurable: false, - enumerable: false, - writable: true, - value: referencedDebugInfo.slice(0), // Clone so that pushing later isn't going into the original - }); - } else { - // $FlowFixMe[method-unbinding] - existingDebugInfo.push.apply(existingDebugInfo, referencedDebugInfo); - } - } - // We also add the debug info to the initializing chunk since the resolution of that promise is - // also blocked by the referenced debug info. By adding it to both we can track it even if the array/element - // is extracted, or if the root is rendered as is. + // We add the debug info to the initializing chunk since the resolution of + // that promise is also blocked by the referenced debug info. By adding it + // to both we can track it even if the array/element/lazy is extracted, or + // if the root is rendered as is. if (parentChunk !== null) { + const referencedDebugInfo = referencedChunk._debugInfo; const parentDebugInfo = parentChunk._debugInfo; for (let i = 0; i < referencedDebugInfo.length; ++i) { const debugInfoEntry = referencedDebugInfo[i]; @@ -1999,7 +2052,7 @@ function getOutlinedModel( // If we're resolving the "owner" or "stack" slot of an Element array, we don't call // transferReferencedDebugInfo because this reference is to a debug chunk. } else { - transferReferencedDebugInfo(initializingChunk, chunk, chunkValue); + transferReferencedDebugInfo(initializingChunk, chunk); } return chunkValue; case PENDING: @@ -2709,14 +2762,47 @@ function incrementChunkDebugInfo( } } +function addDebugInfo(chunk: SomeChunk, debugInfo: ReactDebugInfo): void { + const value = resolveLazy(chunk.value); + if ( + typeof value === 'object' && + value !== null && + (isArray(value) || + typeof value[ASYNC_ITERATOR] === 'function' || + value.$$typeof === REACT_ELEMENT_TYPE || + value.$$typeof === REACT_LAZY_TYPE) + ) { + if (isArray(value._debugInfo)) { + // $FlowFixMe[method-unbinding] + value._debugInfo.push.apply(value._debugInfo, debugInfo); + } else { + Object.defineProperty((value: any), '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } else { + // $FlowFixMe[method-unbinding] + chunk._debugInfo.push.apply(chunk._debugInfo, debugInfo); + } +} + function resolveChunkDebugInfo( streamState: StreamState, chunk: SomeChunk, ): void { if (__DEV__ && enableAsyncDebugInfo) { - // Push the currently resolving chunk's debug info representing the stream on the Promise - // that was waiting on the stream. - chunk._debugInfo.push({awaited: streamState._debugInfo}); + // Add the currently resolving chunk's debug info representing the stream + // to the Promise that was waiting on the stream, or its underlying value. + const debugInfo: ReactDebugInfo = [{awaited: streamState._debugInfo}]; + if (chunk.status === PENDING || chunk.status === BLOCKED) { + const boundAddDebugInfo = addDebugInfo.bind(null, chunk, debugInfo); + chunk.then(boundAddDebugInfo, boundAddDebugInfo); + } else { + addDebugInfo(chunk, debugInfo); + } } } @@ -2909,7 +2995,8 @@ function resolveStream>( const resolveListeners = chunk.value; if (__DEV__) { - // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. + // Initialize any debug info and block the initializing chunk on any + // unresolved entries. if (chunk._debugChunk != null) { const prevHandler = initializingHandler; const prevChunk = initializingChunk; @@ -2923,7 +3010,6 @@ function resolveStream>( } try { initializeDebugChunk(response, chunk); - chunk._debugChunk = null; if (initializingHandler !== null) { if (initializingHandler.errored) { // Ignore error parsing debug info, we'll report the original error instead. @@ -2947,7 +3033,7 @@ function resolveStream>( resolvedChunk.value = stream; resolvedChunk.reason = controller; if (resolveListeners !== null) { - wakeChunk(resolveListeners, chunk.value, chunk); + wakeChunk(resolveListeners, chunk.value, (chunk: any)); } } diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 0baee5a1f5098..b0f539bf2572c 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -327,8 +327,8 @@ describe('ReactFlight', () => { const transport = ReactNoopFlightServer.render(root); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: 12}, @@ -346,7 +346,7 @@ describe('ReactFlight', () => { ] : undefined, ); - ReactNoop.render(await promise); + ReactNoop.render(result); }); expect(ReactNoop).toMatchRenderedOutput(Hello, Seb Smith); @@ -1378,9 +1378,7 @@ describe('ReactFlight', () => { environmentName: 'Server', }, ], - findSourceMapURLCalls: [ - [__filename, 'Server'], - [__filename, 'Server'], + findSourceMapURLCalls: expect.arrayContaining([ // TODO: What should we request here? The outer () or the inner (inspected-page.html)? ['inspected-page.html:29:11), ', 'Server'], [ @@ -1389,8 +1387,7 @@ describe('ReactFlight', () => { ], ['file:///testing.js', 'Server'], ['', 'Server'], - [__filename, 'Server'], - ], + ]), }); } else { expect(errors.map(getErrorForJestMatcher)).toEqual([ @@ -2785,8 +2782,8 @@ describe('ReactFlight', () => { ); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: gate(flags => flags.enableAsyncDebugInfo) ? 22 : 20}, @@ -2803,11 +2800,10 @@ describe('ReactFlight', () => { ] : 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(getDebugInfo(thirdPartyChildren[0])).toEqual( + expect(getDebugInfo(await thirdPartyChildren[0])).toEqual( __DEV__ ? [ {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, // Clamped to the start @@ -2910,8 +2906,8 @@ describe('ReactFlight', () => { ); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: 16}, @@ -2924,17 +2920,10 @@ describe('ReactFlight', () => { transport: expect.arrayContaining([]), }, }, - { - time: 16, - }, - { - time: 16, - }, {time: 31}, ] : undefined, ); - const result = await promise; const thirdPartyFragment = await result.props.children; expect(getDebugInfo(thirdPartyFragment)).toEqual( __DEV__ @@ -2949,15 +2938,7 @@ describe('ReactFlight', () => { children: {}, }, }, - { - time: 33, - }, - { - time: 33, - }, - { - time: 33, - }, + {time: 33}, ] : undefined, ); @@ -3013,8 +2994,8 @@ describe('ReactFlight', () => { ); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: 16}, @@ -3040,7 +3021,6 @@ describe('ReactFlight', () => { ] : undefined, ); - const result = await promise; ReactNoop.render(result); }); @@ -3891,15 +3871,6 @@ describe('ReactFlight', () => { { time: 13, }, - { - time: 14, - }, - { - time: 15, - }, - { - time: 16, - }, ]); } else { expect(root._debugInfo).toBe(undefined); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index a49e268ebf040..49cc28535e92d 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -27,6 +27,7 @@ let webpackMap; let webpackServerMap; let act; let serverAct; +let getDebugInfo; let React; let ReactDOM; let ReactDOMClient; @@ -48,6 +49,10 @@ describe('ReactFlightDOMBrowser', () => { ReactServerScheduler = require('scheduler'); patchMessageChannel(ReactServerScheduler); serverAct = require('internal-test-utils').serverAct; + getDebugInfo = require('internal-test-utils').getDebugInfo.bind(null, { + ignoreProps: true, + useFixedTime: true, + }); // Simulate the condition resolution @@ -1767,6 +1772,9 @@ describe('ReactFlightDOMBrowser', () => { webpackMap, ), ); + + // Snapshot updates change this formatting, so we let prettier ignore it. + // prettier-ignore const response = await ReactServerDOMClient.createFromReadableStream(stream); @@ -2906,4 +2914,143 @@ describe('ReactFlightDOMBrowser', () => { '
HiSebbie
', ); }); + + it('should fully resolve debug info when transported through a (slow) debug channel', async () => { + function Paragraph({children}) { + return ReactServer.createElement('p', null, children); + } + + let debugReadableStreamController; + + const debugReadableStream = new ReadableStream({ + start(controller) { + debugReadableStreamController = controller; + }, + }); + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + root: ReactServer.createElement( + ReactServer.Fragment, + null, + ReactServer.createElement(Paragraph, null, 'foo'), + ReactServer.createElement(Paragraph, null, 'bar'), + ), + }, + webpackMap, + { + debugChannel: { + writable: new WritableStream({ + write(chunk) { + debugReadableStreamController.enqueue(chunk); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ); + + function ClientRoot({response}) { + const {root} = use(response); + return root; + } + + const [slowDebugStream1, slowDebugStream2] = + createDelayedStream(debugReadableStream).tee(); + + const response = ReactServerDOMClient.createFromReadableStream(stream, { + debugChannel: {readable: slowDebugStream1}, + }); + + const container = document.createElement('div'); + const clientRoot = ReactDOMClient.createRoot(container); + + await act(() => { + clientRoot.render(); + }); + + if (__DEV__) { + const debugStreamReader = slowDebugStream2.getReader(); + while (true) { + const {done} = await debugStreamReader.read(); + if (done) { + break; + } + // Allow the client to process each debug chunk as it arrives. + await act(() => {}); + } + } + + expect(container.innerHTML).toBe('

foo

bar

'); + + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + const result = await response; + const firstParagraph = result.root[0]; + + expect(getDebugInfo(firstParagraph)).toMatchInlineSnapshot(` + [ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Paragraph", + "props": {}, + "stack": [ + [ + "", + "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js", + 2937, + 27, + 2931, + 34, + ], + [ + "serverAct", + "/packages/internal-test-utils/internalAct.js", + 270, + 19, + 231, + 1, + ], + [ + "Object.", + "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js", + 2931, + 18, + 2918, + 89, + ], + ], + }, + { + "time": 0, + }, + { + "awaited": { + "byteSize": 0, + "end": 0, + "name": "RSC stream", + "owner": null, + "start": 0, + "value": { + "value": "stream", + }, + }, + }, + ] + `); + } + }); }); 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 55b434ce3eeff..7aaf4150db087 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -1240,7 +1240,7 @@ describe('ReactFlightDOMEdge', () => { env: 'Server', }); if (gate(flags => flags.enableAsyncDebugInfo)) { - expect(lazyWrapper._debugInfo).toEqual([ + expect(greeting._debugInfo).toEqual([ {time: 12}, greetInfo, {time: 13}, @@ -1259,7 +1259,7 @@ describe('ReactFlightDOMEdge', () => { } // The owner that created the span was the outer server component. // We expect the debug info to be referentially equal to the owner. - expect(greeting._owner).toBe(lazyWrapper._debugInfo[1]); + expect(greeting._owner).toBe(greeting._debugInfo[1]); } else { expect(lazyWrapper._debugInfo).toBe(undefined); expect(greeting._owner).toBe(undefined); @@ -1930,11 +1930,19 @@ describe('ReactFlightDOMEdge', () => { if (__DEV__) { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Component\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Component\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', ); } else { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (at **)', ); } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index f069b23b293c0..59df3c24d6f40 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -722,11 +722,19 @@ describe('ReactFlightDOMNode', () => { if (__DEV__) { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Component (at **)\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Component (at **)\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', ); } else { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (at **)', ); } @@ -861,11 +869,19 @@ describe('ReactFlightDOMNode', () => { if (__DEV__) { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Component (at **)\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Component (at **)\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', ); } else { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (at **)', ); }