diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index f94fadd42962e..c96f6cef6fd30 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -10,6 +10,15 @@ 'use strict'; +function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) { + return '\n in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) + ); +} + const heldValues = []; let finalizationCallback; function FinalizationRegistryMock(callback) { @@ -69,6 +78,14 @@ describe('ReactFlight', () => { error, }; } + componentDidCatch(error, errorInfo) { + expect(error).toBe(this.state.error); + if (this.props.expectedStack !== undefined) { + expect(normalizeCodeLocInfo(errorInfo.componentStack)).toBe( + this.props.expectedStack, + ); + } + } componentDidMount() { expect(this.state.hasError).toBe(true); expect(this.state.error).toBeTruthy(); @@ -900,6 +917,91 @@ describe('ReactFlight', () => { }); }); + it('should include server components in error boundary stacks in dev', async () => { + const ClientErrorBoundary = clientReference(ErrorBoundary); + + function Throw({value}) { + throw value; + } + + const expectedStack = __DEV__ + ? // TODO: This should include Throw but it doesn't have a Fiber. + '\n in div' + '\n in ErrorBoundary (at **)' + '\n in App' + : '\n in div' + '\n in ErrorBoundary (at **)'; + + function App() { + return ( + +
+ +
+
+ ); + } + + const transport = ReactNoopFlightServer.render(, { + onError(x) { + if (__DEV__) { + return 'a dev digest'; + } + if (x instanceof Error) { + return `digest("${x.message}")`; + } else if (Array.isArray(x)) { + return `digest([])`; + } else if (typeof x === 'object' && x !== null) { + return `digest({})`; + } + return `digest(${String(x)})`; + }, + }); + + await act(() => { + startTransition(() => { + ReactNoop.render(ReactNoopFlightClient.read(transport)); + }); + }); + }); + + it('should include server components in warning stacks', async () => { + function Component() { + // Trigger key warning + return
{[]}
; + } + const ClientComponent = clientReference(Component); + + function Indirection({children}) { + return children; + } + + function App() { + return ( + + + + ); + } + + const transport = ReactNoopFlightServer.render(); + + await expect(async () => { + await act(() => { + startTransition(() => { + ReactNoop.render(ReactNoopFlightClient.read(transport)); + }); + }); + }).toErrorDev( + 'Each child in a list should have a unique "key" prop.\n' + + '\n' + + 'Check the render method of `Component`. See https://reactjs.org/link/warning-keys for more information.\n' + + ' in span (at **)\n' + + ' in Component (at **)\n' + + ' in Indirection (at **)\n' + + ' in App (at **)', + ); + }); + it('should trigger the inner most error boundary inside a Client Component', async () => { function ServerComponent() { throw new Error('This was thrown in the Server Component.'); diff --git a/packages/react-devtools-shared/src/__tests__/componentStacks-test.js b/packages/react-devtools-shared/src/__tests__/componentStacks-test.js index 3f3ce3774332e..3d17546e39d19 100644 --- a/packages/react-devtools-shared/src/__tests__/componentStacks-test.js +++ b/packages/react-devtools-shared/src/__tests__/componentStacks-test.js @@ -86,4 +86,41 @@ describe('component stack', () => { '\n in Example (at **)', ); }); + + // @reactVersion >=18.3 + it('should log the current component stack with debug info from promises', () => { + const Child = () => { + console.error('Test error.'); + console.warn('Test warning.'); + return null; + }; + const ChildPromise = Promise.resolve(); + ChildPromise.status = 'fulfilled'; + ChildPromise.value = ; + ChildPromise._debugInfo = [ + { + name: 'ServerComponent', + env: 'Server', + }, + ]; + const Parent = () => ChildPromise; + const Grandparent = () => ; + + act(() => render()); + + expect(mockError).toHaveBeenCalledWith( + 'Test error.', + '\n in Child (at **)' + + '\n in ServerComponent (at **)' + + '\n in Parent (at **)' + + '\n in Grandparent (at **)', + ); + expect(mockWarn).toHaveBeenCalledWith( + 'Test warning.', + '\n in Child (at **)' + + '\n in ServerComponent (at **)' + + '\n in Parent (at **)' + + '\n in Grandparent (at **)', + ); + }); }); diff --git a/packages/react-devtools-shared/src/backend/DevToolsComponentStackFrame.js b/packages/react-devtools-shared/src/backend/DevToolsComponentStackFrame.js index 98d944477d860..4e0e334ce86a6 100644 --- a/packages/react-devtools-shared/src/backend/DevToolsComponentStackFrame.js +++ b/packages/react-devtools-shared/src/backend/DevToolsComponentStackFrame.js @@ -50,6 +50,13 @@ export function describeBuiltInComponentFrame( return '\n' + prefix + name; } +export function describeDebugInfoFrame(name: string, env: ?string): string { + return describeBuiltInComponentFrame( + name + (env ? ' (' + env + ')' : ''), + null, + ); +} + let reentry = false; let componentFrameCache; if (__DEV__) { diff --git a/packages/react-devtools-shared/src/backend/DevToolsFiberComponentStack.js b/packages/react-devtools-shared/src/backend/DevToolsFiberComponentStack.js index 410121c7c96a5..77f0adf37ea4e 100644 --- a/packages/react-devtools-shared/src/backend/DevToolsFiberComponentStack.js +++ b/packages/react-devtools-shared/src/backend/DevToolsFiberComponentStack.js @@ -19,6 +19,7 @@ import { describeBuiltInComponentFrame, describeFunctionComponentFrame, describeClassComponentFrame, + describeDebugInfoFrame, } from './DevToolsComponentStackFrame'; export function describeFiber( @@ -87,6 +88,16 @@ export function getStackByFiberInDevAndProd( let node: Fiber = workInProgress; do { info += describeFiber(workTagMap, node, currentDispatcherRef); + // Add any Server Component stack frames in reverse order. + const debugInfo = node._debugInfo; + if (debugInfo) { + for (let i = debugInfo.length - 1; i >= 0; i--) { + const entry = debugInfo[i]; + if (typeof entry.name === 'string') { + info += describeDebugInfoFrame(entry.name, entry.env); + } + } + } // $FlowFixMe[incompatible-type] we bail out when we get a null node = node.return; } while (node); diff --git a/packages/react-reconciler/src/ReactFiberComponentStack.js b/packages/react-reconciler/src/ReactFiberComponentStack.js index 3300dec292c9a..36e22e8a9b1f2 100644 --- a/packages/react-reconciler/src/ReactFiberComponentStack.js +++ b/packages/react-reconciler/src/ReactFiberComponentStack.js @@ -26,6 +26,7 @@ import { describeBuiltInComponentFrame, describeFunctionComponentFrame, describeClassComponentFrame, + describeDebugInfoFrame, } from 'shared/ReactComponentStackFrame'; function describeFiber(fiber: Fiber): string { @@ -64,6 +65,18 @@ export function getStackByFiberInDevAndProd(workInProgress: Fiber): string { let node: Fiber = workInProgress; do { info += describeFiber(node); + if (__DEV__) { + // Add any Server Component stack frames in reverse order. + const debugInfo = node._debugInfo; + if (debugInfo) { + for (let i = debugInfo.length - 1; i >= 0; i--) { + const entry = debugInfo[i]; + if (typeof entry.name === 'string') { + info += describeDebugInfoFrame(entry.name, entry.env); + } + } + } + } // $FlowFixMe[incompatible-type] we bail out when we get a null node = node.return; } while (node); diff --git a/packages/shared/ReactComponentStackFrame.js b/packages/shared/ReactComponentStackFrame.js index 94a6c517d914a..c928f191d7b79 100644 --- a/packages/shared/ReactComponentStackFrame.js +++ b/packages/shared/ReactComponentStackFrame.js @@ -51,6 +51,13 @@ export function describeBuiltInComponentFrame( } } +export function describeDebugInfoFrame(name: string, env: ?string): string { + return describeBuiltInComponentFrame( + name + (env ? ' (' + env + ')' : ''), + null, + ); +} + let reentry = false; let componentFrameCache; if (__DEV__) {