diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 79e05e9e0d89c..62e0a17af06a4 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1766,9 +1766,9 @@ describe('ReactDOMFizzServer', () => { // Intentionally trigger a key warning here. return (
- {children.map(t => ( - {t} - ))} + {children.map(function mapper(t) { + return {t}; + })}
); } @@ -1813,11 +1813,15 @@ describe('ReactDOMFizzServer', () => { '<%s /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements.%s', 'inCorrectTag', '\n' + - ' in inCorrectTag (at **)\n' + - ' in C (at **)\n' + - ' in Suspense (at **)\n' + - ' in div (at **)\n' + - ' in A (at **)', + (gate(flags => flags.enableOwnerStacks) + ? ' in inCorrectTag (at **)\n' + + ' in C (at **)\n' + + ' in A (at **)' + : ' in inCorrectTag (at **)\n' + + ' in C (at **)\n' + + ' in Suspense (at **)\n' + + ' in div (at **)\n' + + ' in A (at **)'), ); mockError.mockClear(); } else { @@ -1833,22 +1837,19 @@ describe('ReactDOMFizzServer', () => { expect(mockError).toHaveBeenCalledWith( 'Each child in a list should have a unique "key" prop.%s%s' + ' See https://react.dev/link/warning-keys for more information.%s', - gate(flags => flags.enableOwnerStacks) - ? // We currently don't track owners in Fizz which is responsible for this frame. - '' - : '\n\nCheck the top-level render call using
.', + '\n\nCheck the render method of `B`.', '', '\n' + - ' in span (at **)\n' + - // TODO: Because this validates after the div has been mounted, it is part of - // the parent stack but since owner stacks will switch to owners this goes away again. (gate(flags => flags.enableOwnerStacks) - ? ' in div (at **)\n' - : '') + - ' in B (at **)\n' + - ' in Suspense (at **)\n' + - ' in div (at **)\n' + - ' in A (at **)', + ? ' in span (at **)\n' + + ' in mapper (at **)\n' + + ' in B (at **)\n' + + ' in A (at **)' + : ' in span (at **)\n' + + ' in B (at **)\n' + + ' in Suspense (at **)\n' + + ' in div (at **)\n' + + ' in A (at **)'), ); } else { expect(mockError).not.toHaveBeenCalled(); @@ -6519,24 +6520,25 @@ describe('ReactDOMFizzServer', () => { mockError(...args.map(normalizeCodeLocInfo)); }; + function App() { + return ( + + + + + + + + ); + } + try { await act(async () => { - const {pipe} = renderToPipeableStream( - - - - - - - , - ); + const {pipe} = renderToPipeableStream(); pipe(writable); }); @@ -6545,17 +6547,29 @@ describe('ReactDOMFizzServer', () => { expect(mockError.mock.calls[0]).toEqual([ 'A script element was rendered with %s. If script element has children it must be a single string. Consider using dangerouslySetInnerHTML or passing a plain string as children.%s', 'a number for children', - componentStack(['script', 'body', 'html']), + componentStack( + gate(flags => flags.enableOwnerStacks) + ? ['script', 'App'] + : ['script', 'body', 'html', 'App'], + ), ]); expect(mockError.mock.calls[1]).toEqual([ 'A script element was rendered with %s. If script element has children it must be a single string. Consider using dangerouslySetInnerHTML or passing a plain string as children.%s', 'an array for children', - componentStack(['script', 'body', 'html']), + componentStack( + gate(flags => flags.enableOwnerStacks) + ? ['script', 'App'] + : ['script', 'body', 'html', 'App'], + ), ]); expect(mockError.mock.calls[2]).toEqual([ 'A script element was rendered with %s. If script element has children it must be a single string. Consider using dangerouslySetInnerHTML or passing a plain string as children.%s', 'something unexpected for children', - componentStack(['script', 'body', 'html']), + componentStack( + gate(flags => flags.enableOwnerStacks) + ? ['script', 'App'] + : ['script', 'body', 'html', 'App'], + ), ]); } else { expect(mockError.mock.calls.length).toBe(0); @@ -8148,4 +8162,91 @@ describe('ReactDOMFizzServer', () => { expect(document.body.textContent).toBe('HelloWorld'); }); + + // @gate __DEV__ && enableOwnerStacks + it('can get the component owner stacks during rendering in dev', async () => { + let stack; + + function Foo() { + return ; + } + function Bar() { + return ( +
+ +
+ ); + } + function Baz() { + stack = React.captureOwnerStack(); + return hi; + } + + await act(() => { + const {pipe} = renderToPipeableStream( +
+ +
, + ); + pipe(writable); + }); + + expect(normalizeCodeLocInfo(stack)).toBe( + '\n in Bar (at **)' + '\n in Foo (at **)', + ); + }); + + // @gate __DEV__ && enableOwnerStacks + it('can get the component owner stacks for onError in dev', async () => { + const thrownError = new Error('hi'); + let caughtError; + let parentStack; + let ownerStack; + + function Foo() { + return ; + } + function Bar() { + return ( +
+ +
+ ); + } + function Baz() { + throw thrownError; + } + + await expect(async () => { + await act(() => { + const {pipe} = renderToPipeableStream( +
+ +
, + { + onError(error, errorInfo) { + caughtError = error; + parentStack = errorInfo.componentStack; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ); + pipe(writable); + }); + }).rejects.toThrow(thrownError); + + expect(caughtError).toBe(thrownError); + expect(normalizeCodeLocInfo(parentStack)).toBe( + '\n in Baz (at **)' + + '\n in div (at **)' + + '\n in Bar (at **)' + + '\n in Foo (at **)' + + '\n in div (at **)', + ); + expect(normalizeCodeLocInfo(ownerStack)).toBe( + '\n in Bar (at **)' + '\n in Foo (at **)', + ); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactServerRendering-test.js b/packages/react-dom/src/__tests__/ReactServerRendering-test.js index a41488600655d..065f7cadd7f85 100644 --- a/packages/react-dom/src/__tests__/ReactServerRendering-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRendering-test.js @@ -835,21 +835,30 @@ describe('ReactDOMServer', () => { expect(() => ReactDOMServer.renderToString()).toErrorDev([ 'Invalid ARIA attribute `ariaTypo`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' + - ' in span (at **)\n' + - ' in b (at **)\n' + - ' in C (at **)\n' + - ' in font (at **)\n' + - ' in B (at **)\n' + - ' in Child (at **)\n' + - ' in span (at **)\n' + - ' in div (at **)\n' + - ' in App (at **)', + (gate(flags => flags.enableOwnerStacks) + ? ' in span (at **)\n' + + ' in B (at **)\n' + + ' in Child (at **)\n' + + ' in App (at **)' + : ' in span (at **)\n' + + ' in b (at **)\n' + + ' in C (at **)\n' + + ' in font (at **)\n' + + ' in B (at **)\n' + + ' in Child (at **)\n' + + ' in span (at **)\n' + + ' in div (at **)\n' + + ' in App (at **)'), 'Invalid ARIA attribute `ariaTypo2`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' + - ' in span (at **)\n' + - ' in Child (at **)\n' + - ' in span (at **)\n' + - ' in div (at **)\n' + - ' in App (at **)', + (gate(flags => flags.enableOwnerStacks) + ? ' in span (at **)\n' + + ' in Child (at **)\n' + + ' in App (at **)' + : ' in span (at **)\n' + + ' in Child (at **)\n' + + ' in span (at **)\n' + + ' in div (at **)\n' + + ' in App (at **)'), ]); }); @@ -885,9 +894,11 @@ describe('ReactDOMServer', () => { expect(() => ReactDOMServer.renderToString()).toErrorDev([ // ReactDOMServer(App > div > span) 'Invalid ARIA attribute `ariaTypo`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' + - ' in span (at **)\n' + - ' in div (at **)\n' + - ' in App (at **)', + (gate(flags => flags.enableOwnerStacks) + ? ' in span (at **)\n' + ' in App (at **)' + : ' in span (at **)\n' + + ' in div (at **)\n' + + ' in App (at **)'), // ReactDOMServer(App > div > Child) >>> ReactDOMServer(App2) >>> ReactDOMServer(blink) 'Invalid ARIA attribute `ariaTypo2`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' + ' in blink (at **)', @@ -898,15 +909,21 @@ describe('ReactDOMServer', () => { ' in App2 (at **)', // ReactDOMServer(App > div > Child > span) 'Invalid ARIA attribute `ariaTypo4`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' + - ' in span (at **)\n' + - ' in Child (at **)\n' + - ' in div (at **)\n' + - ' in App (at **)', + (gate(flags => flags.enableOwnerStacks) + ? ' in span (at **)\n' + + ' in Child (at **)\n' + + ' in App (at **)' + : ' in span (at **)\n' + + ' in Child (at **)\n' + + ' in div (at **)\n' + + ' in App (at **)'), // ReactDOMServer(App > div > font) 'Invalid ARIA attribute `ariaTypo5`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' + - ' in font (at **)\n' + - ' in div (at **)\n' + - ' in App (at **)', + (gate(flags => flags.enableOwnerStacks) + ? ' in font (at **)\n' + ' in App (at **)' + : ' in font (at **)\n' + + ' in div (at **)\n' + + ' in App (at **)'), ]); }); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 8e58b5c44aa84..beee88de2549b 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -36,6 +36,7 @@ import type { Transition, } from './ReactFiberTracingMarkerComponent'; import type {ConcurrentUpdate} from './ReactFiberConcurrentUpdates'; +import type {ComponentStackNode} from 'react-server/src/ReactFizzComponentStack'; // Unwind Circular: moved from ReactFiberHooks.old export type HookType = @@ -439,5 +440,5 @@ export type Dispatcher = { export type AsyncDispatcher = { getCacheForType: (resourceType: () => T) => T, // DEV-only (or !disableStringRefs) - getOwner: () => null | Fiber | ReactComponentInfo, + getOwner: () => null | Fiber | ReactComponentInfo | ComponentStackNode, }; diff --git a/packages/react-server/src/ReactFizzAsyncDispatcher.js b/packages/react-server/src/ReactFizzAsyncDispatcher.js index 8e8cb2e219992..3a548ae138039 100644 --- a/packages/react-server/src/ReactFizzAsyncDispatcher.js +++ b/packages/react-server/src/ReactFizzAsyncDispatcher.js @@ -8,9 +8,12 @@ */ import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes'; +import type {ComponentStackNode} from './ReactFizzComponentStack'; import {disableStringRefs} from 'shared/ReactFeatureFlags'; +import {currentTaskInDEV} from './ReactFizzCurrentTask'; + function getCacheForType(resourceType: () => T): T { throw new Error('Not implemented.'); } @@ -19,8 +22,14 @@ export const DefaultAsyncDispatcher: AsyncDispatcher = ({ getCacheForType, }: any); -if (__DEV__ || !disableStringRefs) { - // Fizz never tracks owner but the JSX runtime looks for this. +if (__DEV__) { + DefaultAsyncDispatcher.getOwner = (): ComponentStackNode | null => { + if (currentTaskInDEV === null) { + return null; + } + return currentTaskInDEV.componentStack; + }; +} else if (!disableStringRefs) { DefaultAsyncDispatcher.getOwner = (): null => { return null; }; diff --git a/packages/react-server/src/ReactFizzCallUserSpace.js b/packages/react-server/src/ReactFizzCallUserSpace.js new file mode 100644 index 0000000000000..4a376607dc6df --- /dev/null +++ b/packages/react-server/src/ReactFizzCallUserSpace.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {LazyComponent} from 'react/src/ReactLazy'; + +// These indirections exists so we can exclude its stack frame in DEV (and anything below it). +// TODO: Consider marking the whole bundle instead of these boundaries. + +/** @noinline */ +export function callComponentInDEV( + Component: (p: Props, arg: Arg) => R, + props: Props, + secondArg: Arg, +): R { + return Component(props, secondArg); +} + +interface ClassInstance { + render(): R; +} + +/** @noinline */ +export function callRenderInDEV(instance: ClassInstance): R { + return instance.render(); +} + +/** @noinline */ +export function callLazyInitInDEV(lazy: LazyComponent): any { + const payload = lazy._payload; + const init = lazy._init; + return init(payload); +} diff --git a/packages/react-server/src/ReactFizzComponentStack.js b/packages/react-server/src/ReactFizzComponentStack.js index 84b4d82d8d45f..a16b2c5f91351 100644 --- a/packages/react-server/src/ReactFizzComponentStack.js +++ b/packages/react-server/src/ReactFizzComponentStack.js @@ -7,27 +7,39 @@ * @flow */ +import type {ReactComponentInfo} from 'shared/ReactTypes'; + import { describeBuiltInComponentFrame, describeFunctionComponentFrame, describeClassComponentFrame, } from 'shared/ReactComponentStackFrame'; +import {enableOwnerStacks} from 'shared/ReactFeatureFlags'; + +import {formatOwnerStack} from './ReactFizzOwnerStack'; + // DEV-only reverse linked list representing the current component stack type BuiltInComponentStackNode = { tag: 0, parent: null | ComponentStackNode, type: string, + owner?: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack?: null | string | Error, // DEV only }; type FunctionComponentStackNode = { tag: 1, parent: null | ComponentStackNode, type: Function, + owner?: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack?: null | string | Error, // DEV only }; type ClassComponentStackNode = { tag: 2, parent: null | ComponentStackNode, type: Function, + owner?: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack?: null | string | Error, // DEV only }; export type ComponentStackNode = | BuiltInComponentStackNode @@ -60,3 +72,82 @@ export function getStackByComponentStackNode( return '\nError generating stack: ' + x.message + '\n' + x.stack; } } + +function describeFunctionComponentFrameWithoutLineNumber(fn: Function): string { + // We use this because we don't actually want to describe the line of the component + // but just the component name. + const name = fn ? fn.displayName || fn.name : ''; + return name ? describeBuiltInComponentFrame(name) : ''; +} + +export function getOwnerStackByComponentStackNodeInDev( + componentStack: ComponentStackNode, +): string { + if (!enableOwnerStacks || !__DEV__) { + return ''; + } + try { + let info = ''; + + // The owner stack of the current component will be where it was created, i.e. inside its owner. + // There's no actual name of the currently executing component. Instead, that is available + // on the regular stack that's currently executing. However, for built-ins there is no such + // named stack frame and it would be ignored as being internal anyway. Therefore we add + // add one extra frame just to describe the "current" built-in component by name. + // Similarly, if there is no owner at all, then there's no stack frame so we add the name + // of the root component to the stack to know which component is currently executing. + switch (componentStack.tag) { + case 0: + info += describeBuiltInComponentFrame(componentStack.type); + break; + case 1: + case 2: + if (!componentStack.owner) { + // Only if we have no other data about the callsite do we add + // the component name as the single stack frame. + info += describeFunctionComponentFrameWithoutLineNumber( + componentStack.type, + ); + } + break; + } + + let owner: void | null | ComponentStackNode | ReactComponentInfo = + componentStack; + + while (owner) { + if (typeof owner.tag === 'number') { + const node: ComponentStackNode = (owner: any); + owner = node.owner; + let debugStack = node.stack; + // If we don't actually print the stack if there is no owner of this JSX element. + // In a real app it's typically not useful since the root app is always controlled + // by the framework. These also tend to have noisy stacks because they're not rooted + // in a React render but in some imperative bootstrapping code. It could be useful + // if the element was created in module scope. E.g. hoisted. We could add a a single + // stack frame for context for example but it doesn't say much if that's a wrapper. + if (owner && debugStack) { + if (typeof debugStack !== 'string') { + // Stash the formatted stack so that we can avoid redoing the filtering. + node.stack = debugStack = formatOwnerStack(debugStack); + } + if (debugStack !== '') { + info += '\n' + debugStack; + } + } + } else if (typeof owner.stack === 'string') { + // Server Component + if (owner.stack !== '') { + info += '\n' + owner.stack; + } + const componentInfo: ReactComponentInfo = (owner: any); + owner = componentInfo.owner; + } else { + break; + } + } + return info; + } catch (x) { + return '\nError generating stack: ' + x.message + '\n' + x.stack; + } +} diff --git a/packages/react-server/src/ReactFizzCurrentTask.js b/packages/react-server/src/ReactFizzCurrentTask.js new file mode 100644 index 0000000000000..3bd62f0a5cde0 --- /dev/null +++ b/packages/react-server/src/ReactFizzCurrentTask.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Task} from './ReactFizzServer'; + +// DEV-only global reference to the currently executing task +export let currentTaskInDEV: null | Task = null; + +export function setCurrentTaskInDEV(task: null | Task): void { + if (__DEV__) { + currentTaskInDEV = task; + } +} diff --git a/packages/react-server/src/ReactFizzOwnerStack.js b/packages/react-server/src/ReactFizzOwnerStack.js new file mode 100644 index 0000000000000..8d6b5d94fd0db --- /dev/null +++ b/packages/react-server/src/ReactFizzOwnerStack.js @@ -0,0 +1,117 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {REACT_LAZY_TYPE} from 'shared/ReactSymbols'; + +import { + callLazyInitInDEV, + callComponentInDEV, + callRenderInDEV, +} from './ReactFizzCallUserSpace'; + +// TODO: Make this configurable on the root. +const externalRegExp = /\/node\_modules\/|\(\\)/; + +let callComponentFrame: null | string = null; +let callIteratorFrame: null | string = null; +let callLazyInitFrame: null | string = null; + +function isNotExternal(stackFrame: string): boolean { + return !externalRegExp.test(stackFrame); +} + +function initCallComponentFrame(): string { + // Extract the stack frame of the callComponentInDEV function. + const error = callComponentInDEV(Error, 'react-stack-top-frame', {}); + const stack = error.stack; + const startIdx = stack.startsWith('Error: react-stack-top-frame\n') ? 29 : 0; + const endIdx = stack.indexOf('\n', startIdx); + if (endIdx === -1) { + return stack.slice(startIdx); + } + return stack.slice(startIdx, endIdx); +} + +function initCallRenderFrame(): string { + // Extract the stack frame of the callRenderInDEV function. + try { + (callRenderInDEV: any)({render: null}); + return ''; + } catch (error) { + const stack = error.stack; + const startIdx = stack.startsWith('TypeError: ') + ? stack.indexOf('\n') + 1 + : 0; + const endIdx = stack.indexOf('\n', startIdx); + if (endIdx === -1) { + return stack.slice(startIdx); + } + return stack.slice(startIdx, endIdx); + } +} + +function initCallLazyInitFrame(): string { + // Extract the stack frame of the callLazyInitInDEV function. + const error = callLazyInitInDEV({ + $$typeof: REACT_LAZY_TYPE, + _init: Error, + _payload: 'react-stack-top-frame', + }); + const stack = error.stack; + const startIdx = stack.startsWith('Error: react-stack-top-frame\n') ? 29 : 0; + const endIdx = stack.indexOf('\n', startIdx); + if (endIdx === -1) { + return stack.slice(startIdx); + } + return stack.slice(startIdx, endIdx); +} + +function filterDebugStack(error: Error): string { + // Since stacks can be quite large and we pass a lot of them, we filter them out eagerly + // to save bandwidth even in DEV. We'll also replay these stacks on the client so by + // stripping them early we avoid that overhead. Otherwise we'd normally just rely on + // the DevTools or framework's ignore lists to filter them out. + let stack = error.stack; + if (stack.startsWith('Error: react-stack-top-frame\n')) { + // V8's default formatting prefixes with the error message which we + // don't want/need. + stack = stack.slice(29); + } + const frames = stack.split('\n').slice(1); + if (callComponentFrame === null) { + callComponentFrame = initCallComponentFrame(); + } + let lastFrameIdx = frames.indexOf(callComponentFrame); + if (lastFrameIdx === -1) { + if (callLazyInitFrame === null) { + callLazyInitFrame = initCallLazyInitFrame(); + } + lastFrameIdx = frames.indexOf(callLazyInitFrame); + if (lastFrameIdx === -1) { + if (callIteratorFrame === null) { + callIteratorFrame = initCallRenderFrame(); + } + lastFrameIdx = frames.indexOf(callIteratorFrame); + } + } + if (lastFrameIdx !== -1) { + // Cut off everything after our "callComponent" slot since it'll be Fiber internals. + frames.length = lastFrameIdx; + } else { + // We didn't find any internal callsite out to user space. + // This means that this was called outside an owner or the owner is fully internal. + // To keep things light we exclude the entire trace in this case. + return ''; + } + return frames.filter(isNotExternal).join('\n'); +} + +export function formatOwnerStack(ownerStackTrace: Error): string { + return filterDebugStack(ownerStackTrace); +} diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index b5e9ed28fde52..6c21a1828da58 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -20,6 +20,7 @@ import type { Wakeable, Thenable, ReactFormState, + ReactComponentInfo, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -113,8 +114,17 @@ import { getActionStateMatchingIndex, } from './ReactFizzHooks'; import {DefaultAsyncDispatcher} from './ReactFizzAsyncDispatcher'; -import {getStackByComponentStackNode} from './ReactFizzComponentStack'; +import { + getStackByComponentStackNode, + getOwnerStackByComponentStackNodeInDev, +} from './ReactFizzComponentStack'; import {emptyTreeContext, pushTreeContext} from './ReactFizzTreeContext'; +import {currentTaskInDEV, setCurrentTaskInDEV} from './ReactFizzCurrentTask'; +import { + callLazyInitInDEV, + callComponentInDEV, + callRenderInDEV, +} from './ReactFizzCallUserSpace'; import { getIteratorFn, @@ -790,14 +800,16 @@ function createPendingSegment( }; } -// DEV-only global reference to the currently executing task -let currentTaskInDEV: null | Task = null; function getCurrentStackInDEV(): string { if (__DEV__) { if (currentTaskInDEV === null || currentTaskInDEV.componentStack === null) { return ''; } - // TODO: Support owner based stacks for logs during SSR. + if (enableOwnerStacks) { + return getOwnerStackByComponentStackNodeInDev( + currentTaskInDEV.componentStack, + ); + } return getStackByComponentStackNode(currentTaskInDEV.componentStack); } return ''; @@ -810,7 +822,18 @@ function getStackFromNode(stackNode: ComponentStackNode): string { function createBuiltInComponentStack( task: Task, type: string, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): ComponentStackNode { + if (__DEV__) { + return { + tag: 0, + parent: task.componentStack, + type, + owner, + stack, + }; + } return { tag: 0, parent: task.componentStack, @@ -820,7 +843,18 @@ function createBuiltInComponentStack( function createFunctionComponentStack( task: Task, type: Function, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): ComponentStackNode { + if (__DEV__) { + return { + tag: 1, + parent: task.componentStack, + type, + owner, + stack, + }; + } return { tag: 1, parent: task.componentStack, @@ -830,7 +864,18 @@ function createFunctionComponentStack( function createClassComponentStack( task: Task, type: Function, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): ComponentStackNode { + if (__DEV__) { + return { + tag: 2, + parent: task.componentStack, + type, + owner, + stack, + }; + } return { tag: 2, parent: task.componentStack, @@ -841,14 +886,16 @@ function createClassComponentStack( function createComponentStackFromType( task: Task, type: Function | string, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): ComponentStackNode { if (typeof type === 'string') { - return createBuiltInComponentStack(task, type); + return createBuiltInComponentStack(task, type, owner, stack); } if (shouldConstruct(type)) { - return createClassComponentStack(task, type); + return createClassComponentStack(task, type, owner, stack); } - return createFunctionComponentStack(task, type); + return createFunctionComponentStack(task, type, owner, stack); } type ThrownInfo = { @@ -967,6 +1014,8 @@ function renderSuspenseBoundary( someTask: Task, keyPath: KeyNode, props: Object, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { if (someTask.replay !== null) { // If we're replaying through this pass, it means we're replaying through @@ -989,7 +1038,7 @@ function renderSuspenseBoundary( // If we end up creating the fallback task we need it to have the correct stack which is // the stack for the boundary itself. We stash it here so we can use it if needed later const suspenseComponentStack = (task.componentStack = - createBuiltInComponentStack(task, 'Suspense')); + createBuiltInComponentStack(task, 'Suspense', owner, stack)); const prevKeyPath = task.keyPath; const parentBoundary = task.blockedBoundary; @@ -1162,12 +1211,14 @@ function replaySuspenseBoundary( childSlots: ResumeSlots, fallbackNodes: Array, fallbackSlots: ResumeSlots, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { const previousComponentStack = task.componentStack; // If we end up creating the fallback task we need it to have the correct stack which is // the stack for the boundary itself. We stash it here so we can use it if needed later const suspenseComponentStack = (task.componentStack = - createBuiltInComponentStack(task, 'Suspense')); + createBuiltInComponentStack(task, 'Suspense', owner, stack)); const prevKeyPath = task.keyPath; const previousReplaySet: ReplaySet = task.replay; @@ -1295,9 +1346,16 @@ function renderBackupSuspenseBoundary( task: Task, keyPath: KeyNode, props: Object, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ) { const previousComponentStack = task.componentStack; - task.componentStack = createBuiltInComponentStack(task, 'Suspense'); + task.componentStack = createBuiltInComponentStack( + task, + 'Suspense', + owner, + stack, + ); const content = props.children; const segment = task.blockedSegment; @@ -1322,9 +1380,11 @@ function renderHostElement( keyPath: KeyNode, type: string, props: Object, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { const previousComponentStack = task.componentStack; - task.componentStack = createBuiltInComponentStack(task, type); + task.componentStack = createBuiltInComponentStack(task, type, owner, stack); const segment = task.blockedSegment; if (segment === null) { // Replay @@ -1406,7 +1466,12 @@ function renderWithHooks( componentIdentity, prevThenableState, ); - const result = Component(props, secondArg); + let result; + if (__DEV__) { + result = callComponentInDEV(Component, props, secondArg); + } else { + result = Component(props, secondArg); + } return finishHooks(Component, props, result, secondArg); } @@ -1418,7 +1483,12 @@ function finishClassComponent( Component: any, props: any, ): ReactNodeList { - const nextChildren = instance.render(); + let nextChildren; + if (__DEV__) { + nextChildren = callRenderInDEV(instance); + } else { + nextChildren = instance.render(); + } if (__DEV__) { if (instance.props !== props) { @@ -1504,10 +1574,17 @@ function renderClassComponent( keyPath: KeyNode, Component: any, props: any, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { const resolvedProps = resolveClassComponentProps(Component, props); const previousComponentStack = task.componentStack; - task.componentStack = createClassComponentStack(task, Component); + task.componentStack = createClassComponentStack( + task, + Component, + owner, + stack, + ); const maskedContext = !disableLegacyContext ? getMaskedContext(Component, task.legacyContext) : undefined; @@ -1542,13 +1619,20 @@ function renderFunctionComponent( keyPath: KeyNode, Component: any, props: any, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { let legacyContext; if (!disableLegacyContext) { legacyContext = getMaskedContext(Component, task.legacyContext); } const previousComponentStack = task.componentStack; - task.componentStack = createFunctionComponentStack(task, Component); + task.componentStack = createFunctionComponentStack( + task, + Component, + owner, + stack, + ); if (__DEV__) { if ( @@ -1751,9 +1835,16 @@ function renderForwardRef( type: any, props: Object, ref: any, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { const previousComponentStack = task.componentStack; - task.componentStack = createFunctionComponentStack(task, type.render); + task.componentStack = createFunctionComponentStack( + task, + type.render, + owner, + stack, + ); let propsWithoutRef; if (enableRefAsProp && 'ref' in props) { @@ -1803,13 +1894,24 @@ function renderMemo( type: any, props: Object, ref: any, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { const innerType = type.type; const resolvedProps = resolveDefaultPropsOnNonClassComponent( innerType, props, ); - renderElement(request, task, keyPath, innerType, resolvedProps, ref); + renderElement( + request, + task, + keyPath, + innerType, + resolvedProps, + ref, + owner, + stack, + ); } function renderContextConsumer( @@ -1876,17 +1978,33 @@ function renderLazyComponent( lazyComponent: LazyComponentType, props: Object, ref: any, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { const previousComponentStack = task.componentStack; - task.componentStack = createBuiltInComponentStack(task, 'Lazy'); - const payload = lazyComponent._payload; - const init = lazyComponent._init; - const Component = init(payload); + task.componentStack = createBuiltInComponentStack(task, 'Lazy', owner, stack); + let Component; + if (__DEV__) { + Component = callLazyInitInDEV(lazyComponent); + } else { + const payload = lazyComponent._payload; + const init = lazyComponent._init; + Component = init(payload); + } const resolvedProps = resolveDefaultPropsOnNonClassComponent( Component, props, ); - renderElement(request, task, keyPath, Component, resolvedProps, ref); + renderElement( + request, + task, + keyPath, + Component, + resolvedProps, + ref, + owner, + stack, + ); task.componentStack = previousComponentStack; } @@ -1917,18 +2035,28 @@ function renderElement( type: any, props: Object, ref: any, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { if (typeof type === 'function') { if (shouldConstruct(type)) { - renderClassComponent(request, task, keyPath, type, props); + renderClassComponent(request, task, keyPath, type, props, owner, stack); return; } else { - renderFunctionComponent(request, task, keyPath, type, props); + renderFunctionComponent( + request, + task, + keyPath, + type, + props, + owner, + stack, + ); return; } } if (typeof type === 'string') { - renderHostElement(request, task, keyPath, type, props); + renderHostElement(request, task, keyPath, type, props, owner, stack); return; } @@ -1959,7 +2087,12 @@ function renderElement( } case REACT_SUSPENSE_LIST_TYPE: { const preiousComponentStack = task.componentStack; - task.componentStack = createBuiltInComponentStack(task, 'SuspenseList'); + task.componentStack = createBuiltInComponentStack( + task, + 'SuspenseList', + owner, + stack, + ); // TODO: SuspenseList should control the boundaries. const prevKeyPath = task.keyPath; task.keyPath = keyPath; @@ -1983,9 +2116,16 @@ function renderElement( enableSuspenseAvoidThisFallbackFizz && props.unstable_avoidThisFallback === true ) { - renderBackupSuspenseBoundary(request, task, keyPath, props); + renderBackupSuspenseBoundary( + request, + task, + keyPath, + props, + owner, + stack, + ); } else { - renderSuspenseBoundary(request, task, keyPath, props); + renderSuspenseBoundary(request, task, keyPath, props, owner, stack); } return; } @@ -1994,11 +2134,20 @@ function renderElement( if (typeof type === 'object' && type !== null) { switch (type.$$typeof) { case REACT_FORWARD_REF_TYPE: { - renderForwardRef(request, task, keyPath, type, props, ref); + renderForwardRef( + request, + task, + keyPath, + type, + props, + ref, + owner, + stack, + ); return; } case REACT_MEMO_TYPE: { - renderMemo(request, task, keyPath, type, props, ref); + renderMemo(request, task, keyPath, type, props, ref, owner, stack); return; } case REACT_PROVIDER_TYPE: { @@ -2035,7 +2184,16 @@ function renderElement( // Fall through } case REACT_LAZY_TYPE: { - renderLazyComponent(request, task, keyPath, type, props); + renderLazyComponent( + request, + task, + keyPath, + type, + props, + ref, + owner, + stack, + ); return; } } @@ -2115,6 +2273,8 @@ function replayElement( props: Object, ref: any, replay: ReplaySet, + owner: null | ReactComponentInfo | ComponentStackNode, // DEV only + stack: null | Error, // DEV only ): void { // We're replaying. Find the path to follow. const replayNodes = replay.nodes; @@ -2142,7 +2302,7 @@ function replayElement( const currentNode = task.node; task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1}; try { - renderElement(request, task, keyPath, type, props, ref); + renderElement(request, task, keyPath, type, props, ref, owner, stack); if ( task.replay.pendingTasks === 1 && task.replay.nodes.length > 0 @@ -2208,6 +2368,8 @@ function replayElement( node[3], node[4] === null ? [] : node[4][2], node[4] === null ? null : node[4][3], + owner, + stack, ); } // We finished rendering this node, so now we can consume this @@ -2368,6 +2530,9 @@ function renderNodeDestructive( ref = element.ref; } + const owner = __DEV__ ? element._owner : null; + const stack = __DEV__ && enableOwnerStacks ? element._debugStack : null; + const name = getComponentNameFromType(type); const keyOrIndex = key == null ? (childIndex === -1 ? 0 : childIndex) : key; @@ -2389,6 +2554,8 @@ function renderNodeDestructive( props, ref, task.replay, + owner, + stack, ), ); return; @@ -2405,6 +2572,8 @@ function renderNodeDestructive( props, ref, task.replay, + owner, + stack, ); // No matches found for this node. We assume it's already emitted in the // prelude and skip it during the replay. @@ -2422,12 +2591,14 @@ function renderNodeDestructive( type, props, ref, + owner, + stack, ), ); return; } } - renderElement(request, task, keyPath, type, props, ref); + renderElement(request, task, keyPath, type, props, ref, owner, stack); } return; } @@ -2438,11 +2609,21 @@ function renderNodeDestructive( ); case REACT_LAZY_TYPE: { const previousComponentStack = task.componentStack; - task.componentStack = createBuiltInComponentStack(task, 'Lazy'); + task.componentStack = createBuiltInComponentStack( + task, + 'Lazy', + null, + null, + ); const lazyNode: LazyComponentType = (node: any); - const payload = lazyNode._payload; - const init = lazyNode._init; - const resolvedNode = init(payload); + let resolvedNode; + if (__DEV__) { + resolvedNode = callLazyInitInDEV(lazyNode); + } else { + const payload = lazyNode._payload; + const init = lazyNode._init; + resolvedNode = init(payload); + } // We restore the stack before rendering the resolved node because once the Lazy // has resolved any future errors @@ -2504,6 +2685,8 @@ function renderNodeDestructive( task.componentStack = createBuiltInComponentStack( task, 'AsyncIterable', + null, + null, ); // Restore the thenable state before resuming. @@ -2739,14 +2922,54 @@ function warnForMissingKey(request: Request, task: Task, child: mixed): void { } didWarnForKey.add(parentStackFrame); + const componentName = getComponentNameFromType(child.type); + const childOwner = child._owner; + const parentOwner = parentStackFrame.owner; + + let currentComponentErrorInfo = ''; + if (parentOwner && typeof parentOwner.tag === 'number') { + const name = getComponentNameFromType((parentOwner: any).type); + if (name) { + currentComponentErrorInfo = + '\n\nCheck the render method of `' + name + '`.'; + } + } + if (!currentComponentErrorInfo) { + if (componentName) { + currentComponentErrorInfo = `\n\nCheck the top-level render call using <${componentName}>.`; + } + } + + // Usually the current owner is the offender, but if it accepts children as a + // property, it may be the creator of the child that's responsible for + // assigning it a key. + let childOwnerAppendix = ''; + if (childOwner != null && parentOwner !== childOwner) { + let ownerName = null; + if (typeof childOwner.tag === 'number') { + ownerName = getComponentNameFromType((childOwner: any).type); + } else if (typeof childOwner.name === 'string') { + ownerName = childOwner.name; + } + if (ownerName) { + // Give the component that originally created this child. + childOwnerAppendix = ` It was passed a child from ${ownerName}.`; + } + } + // We create a fake component stack for the child to log the stack trace from. - const stackFrame = createComponentStackFromType(task, (child: any).type); + const stackFrame = createComponentStackFromType( + task, + (child: any).type, + (child: any)._owner, + enableOwnerStacks ? (child: any)._debugStack : null, + ); task.componentStack = stackFrame; console.error( 'Each child in a list should have a unique "key" prop.' + '%s%s See https://react.dev/link/warning-keys for more information.', - '', - '', + currentComponentErrorInfo, + childOwnerAppendix, ); task.componentStack = stackFrame.parent; } @@ -3775,7 +3998,7 @@ function retryRenderTask( let prevTaskInDEV = null; if (__DEV__) { prevTaskInDEV = currentTaskInDEV; - currentTaskInDEV = task; + setCurrentTaskInDEV(task); } const childrenLength = segment.children.length; @@ -3852,7 +4075,7 @@ function retryRenderTask( return; } finally { if (__DEV__) { - currentTaskInDEV = prevTaskInDEV; + setCurrentTaskInDEV(prevTaskInDEV); } } } @@ -3870,7 +4093,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void { let prevTaskInDEV = null; if (__DEV__) { prevTaskInDEV = currentTaskInDEV; - currentTaskInDEV = task; + setCurrentTaskInDEV(task); } try { @@ -3939,7 +4162,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void { return; } finally { if (__DEV__) { - currentTaskInDEV = prevTaskInDEV; + setCurrentTaskInDEV(prevTaskInDEV); } } }