From ac2c1a5a5840d5e043d1d7a12a356f226e285c02 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 25 Sep 2025 15:13:15 +0200 Subject: [PATCH] [Flight] Ensure blocked debug info is handled properly (#34524) This PR ensures that server components are reliably included in the DevTools component tree, even if debug info is received delayed, e.g. when using a debug channel. The fix consists of three parts: - We must not unset the debug chunk before all debug info entries are resolved. - We must ensure that the "RSC Stream" IO debug info entry is pushed last, after all other entries were resolved. - We need to transfer the debug info from blocked element chunks onto the lazy node and the element. Ideally, we wouldn't even create a lazy node for blocked elements that are at the root of the JSON payload, because that would basically wrap a lazy in a lazy. This optimization that ensures that everything around the blocked element can proceed is only needed for nested elements. However, we also need it for resolving deduped references in blocked root elements, unless we adapt that logic, which would be a bigger lift. When reloading the Flight fixture, the component tree is now displayed deterministically. Previously, it would sometimes omit synchronous server components. complete --------- Co-authored-by: Sebastian Markbage --- .../react-client/src/ReactFlightClient.js | 212 ++++++++++++------ .../src/__tests__/ReactFlight-test.js | 55 ++--- .../__tests__/ReactFlightDOMBrowser-test.js | 147 ++++++++++++ .../src/__tests__/ReactFlightDOMEdge-test.js | 16 +- .../src/__tests__/ReactFlightDOMNode-test.js | 24 +- 5 files changed, 341 insertions(+), 113 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 6e96f2f037..701d9df33c 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 0baee5a1f5..b0f539bf25 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 a49e268ebf..49cc28535e 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 55b434ce3e..7aaf4150db 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 f069b23b29..59df3c24d6 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 **)', ); }