From a2470c85a99ed1127eaef767e50462c896ddf8e7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 6 May 2024 21:45:09 -0400 Subject: [PATCH 1/2] Add Feature Flag --- packages/shared/ReactFeatureFlags.js | 2 ++ packages/shared/forks/ReactFeatureFlags.native-fb.js | 2 ++ packages/shared/forks/ReactFeatureFlags.native-oss.js | 2 ++ packages/shared/forks/ReactFeatureFlags.test-renderer.js | 2 ++ .../shared/forks/ReactFeatureFlags.test-renderer.native-fb.js | 2 ++ packages/shared/forks/ReactFeatureFlags.test-renderer.www.js | 2 ++ packages/shared/forks/ReactFeatureFlags.www.js | 2 ++ 7 files changed, 14 insertions(+) diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index d537d40b37cc6..ced293f84aaee 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -125,6 +125,8 @@ export const enableEarlyReturnForPropDiffing = false; export const enableAddPropertiesFastPath = false; +export const enableOwnerStacks = __EXPERIMENTAL__; + /** * Enables an expiration time for retry lanes to avoid starvation. */ diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 55e3b466e3e52..8be4796e0ff66 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -100,5 +100,7 @@ export const enableReactTestRendererWarning = false; export const disableLegacyMode = false; export const disableDOMTestUtils = false; +export const enableOwnerStacks = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 89bb87ef43425..4fa287553e3e2 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -107,6 +107,8 @@ export const enableAddPropertiesFastPath = false; export const renameElementSymbol = true; +export const enableOwnerStacks = __EXPERIMENTAL__; + // Profiling Only export const enableProfilerTimer = __PROFILE__; export const enableProfilerCommitHooks = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index bc4b660f6e974..bda1b19c4f325 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -98,5 +98,7 @@ export const enableRenderableContext = true; export const enableReactTestRendererWarning = true; export const disableDefaultPropsExceptForClasses = true; +export const enableOwnerStacks = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 3af9fdaa87784..62f27a8dca536 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -93,5 +93,7 @@ export const enableAddPropertiesFastPath = false; export const renameElementSymbol = false; +export const enableOwnerStacks = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 79b49b2fb4139..c12d49a0fef24 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -93,5 +93,7 @@ export const enableAddPropertiesFastPath = false; export const renameElementSymbol = false; +export const enableOwnerStacks = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 39a21d2f58fdd..c2dcc7aa47f25 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -123,5 +123,7 @@ export const disableLegacyMode = __EXPERIMENTAL__; export const disableDOMTestUtils = false; export const enableEarlyReturnForPropDiffing = false; +export const enableOwnerStacks = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); From 80f37ab5149be39a5e8d55b86aef2e0b675981c1 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 7 May 2024 09:20:30 -0400 Subject: [PATCH 2/2] Track stacks in JSX We track the debug stack using an error. We don't eagerly access the stack to allow it to be lazily generated from the internals. This will be used for passing RSC stacks to the client as well as for user space display. If available, we also track console.createTask separately. This will be used for native stack traces in the native DevTools. --- .../react-client/src/ReactFlightClient.js | 20 ++ .../src/__tests__/ReactFlight-test.js | 151 +++++++++-- .../src/__tests__/ReactFlightDOMEdge-test.js | 18 +- .../react-server/src/ReactFlightServer.js | 248 +++++++++++++++--- packages/react/src/jsx/ReactJSXElement.js | 76 +++++- packages/shared/ReactElementType.js | 9 + packages/shared/ReactTypes.js | 1 + .../error-codes/transform-error-messages.js | 5 + scripts/eslint-rules/prod-error-codes.js | 4 + scripts/jest/setupTests.js | 7 +- 10 files changed, 469 insertions(+), 70 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index eccd16c3171e7..4c823b2b75e68 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -43,6 +43,7 @@ import { enablePostpone, enableRefAsProp, enableFlightReadableStream, + enableOwnerStacks, } from 'shared/ReactFeatureFlags'; import { @@ -563,6 +564,7 @@ function createElement( key: mixed, props: mixed, owner: null | ReactComponentInfo, // DEV-only + stack: null | string, // DEV-only ): React$Element { let element: any; if (__DEV__ && enableRefAsProp) { @@ -623,6 +625,23 @@ function createElement( writable: true, value: null, }); + if (enableOwnerStacks) { + Object.defineProperty(element, '_debugStack', { + configurable: false, + enumerable: false, + writable: true, + value: {stack: stack}, + }); + Object.defineProperty(element, '_debugTask', { + configurable: false, + enumerable: false, + writable: true, + value: null, + }); + } + // TODO: We should be freezing the element but currently, we might write into + // _debugInfo later. We could move it into _store which remains mutable. + Object.freeze(element.props); } return element; } @@ -1003,6 +1022,7 @@ function parseModelTuple( tuple[2], tuple[3], __DEV__ ? (tuple: any)[4] : null, + __DEV__ && enableOwnerStacks ? (tuple: any)[5] : null, ); } return value; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index e7aa52dacb4ae..b49fb79dd12b1 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -21,12 +21,24 @@ if (typeof File === 'undefined' || typeof FormData === 'undefined') { function normalizeCodeLocInfo(str) { return ( str && - str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) { - return '\n in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); }) ); } +function getDebugInfo(obj) { + const debugInfo = obj._debugInfo; + if (debugInfo) { + for (let i = 0; i < debugInfo.length; i++) { + if (typeof debugInfo[i].stack === 'string') { + debugInfo[i].stack = normalizeCodeLocInfo(debugInfo[i].stack); + } + } + } + return debugInfo; +} + const heldValues = []; let finalizationCallback; function FinalizationRegistryMock(callback) { @@ -221,8 +233,19 @@ describe('ReactFlight', () => { await act(async () => { const rootModel = await ReactNoopFlightClient.read(transport); const greeting = rootModel.greeting; - expect(greeting._debugInfo).toEqual( - __DEV__ ? [{name: 'Greeting', env: 'Server', owner: null}] : undefined, + expect(getDebugInfo(greeting)).toEqual( + __DEV__ + ? [ + { + name: 'Greeting', + env: 'Server', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, + }, + ] + : undefined, ); ReactNoop.render(greeting); }); @@ -248,8 +271,19 @@ describe('ReactFlight', () => { await act(async () => { const promise = ReactNoopFlightClient.read(transport); - expect(promise._debugInfo).toEqual( - __DEV__ ? [{name: 'Greeting', env: 'Server', owner: null}] : undefined, + expect(getDebugInfo(promise)).toEqual( + __DEV__ + ? [ + { + name: 'Greeting', + env: 'Server', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, + }, + ] + : undefined, ); ReactNoop.render(await promise); }); @@ -2233,9 +2267,11 @@ describe('ReactFlight', () => { return !; } - const lazy = React.lazy(async () => ({ - default: , - })); + const lazy = React.lazy(async function myLazy() { + return { + default: , + }; + }); function ThirdPartyComponent() { return stranger; @@ -2269,31 +2305,61 @@ describe('ReactFlight', () => { await act(async () => { const promise = ReactNoopFlightClient.read(transport); - expect(promise._debugInfo).toEqual( + expect(getDebugInfo(promise)).toEqual( __DEV__ - ? [{name: 'ServerComponent', env: 'Server', owner: null}] + ? [ + { + name: 'ServerComponent', + env: 'Server', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, + }, + ] : 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(thirdPartyChildren[0]._debugInfo).toEqual( + expect(getDebugInfo(thirdPartyChildren[0])).toEqual( __DEV__ - ? [{name: 'ThirdPartyComponent', env: 'third-party', owner: null}] + ? [ + { + name: 'ThirdPartyComponent', + env: 'third-party', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, + }, + ] : undefined, ); - expect(thirdPartyChildren[1]._debugInfo).toEqual( + expect(getDebugInfo(thirdPartyChildren[1])).toEqual( __DEV__ - ? [{name: 'ThirdPartyLazyComponent', env: 'third-party', owner: null}] + ? [ + { + name: 'ThirdPartyLazyComponent', + env: 'third-party', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in myLazy (at **)\n in lazyInitializer (at **)' + : undefined, + }, + ] : undefined, ); - expect(thirdPartyChildren[2]._debugInfo).toEqual( + expect(getDebugInfo(thirdPartyChildren[2])).toEqual( __DEV__ ? [ { name: 'ThirdPartyFragmentComponent', env: 'third-party', owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, }, ] : undefined, @@ -2357,24 +2423,47 @@ describe('ReactFlight', () => { await act(async () => { const promise = ReactNoopFlightClient.read(transport); - expect(promise._debugInfo).toEqual( + expect(getDebugInfo(promise)).toEqual( __DEV__ - ? [{name: 'ServerComponent', env: 'Server', owner: null}] + ? [ + { + name: 'ServerComponent', + env: 'Server', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, + }, + ] : undefined, ); const result = await promise; const thirdPartyFragment = await result.props.children; - expect(thirdPartyFragment._debugInfo).toEqual( - __DEV__ ? [{name: 'Keyed', env: 'Server', owner: null}] : undefined, + expect(getDebugInfo(thirdPartyFragment)).toEqual( + __DEV__ + ? [ + { + name: 'Keyed', + env: 'Server', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in ServerComponent (at **)' + : undefined, + }, + ] + : undefined, ); // We expect the debug info to be transferred from the inner stream to the outer. - expect(thirdPartyFragment.props.children._debugInfo).toEqual( + expect(getDebugInfo(thirdPartyFragment.props.children)).toEqual( __DEV__ ? [ { name: 'ThirdPartyAsyncIterableComponent', env: 'third-party', owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, }, ] : undefined, @@ -2467,10 +2556,24 @@ describe('ReactFlight', () => { // We've rendered down to the span. expect(greeting.type).toBe('span'); if (__DEV__) { - const greetInfo = {name: 'Greeting', env: 'Server', owner: null}; - expect(greeting._debugInfo).toEqual([ + const greetInfo = { + name: 'Greeting', + env: 'Server', + owner: null, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Object. (at **)' + : undefined, + }; + expect(getDebugInfo(greeting)).toEqual([ greetInfo, - {name: 'Container', env: 'Server', owner: greetInfo}, + { + name: 'Container', + env: 'Server', + owner: greetInfo, + stack: gate(flag => flag.enableOwnerStacks) + ? ' in Greeting (at **)' + : undefined, + }, ]); // The owner that created the span was the outer server component. // We expect the debug info to be referentially equal to the owner. 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 8ea9c1907eb6b..b4dfef0c90365 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -263,7 +263,7 @@ describe('ReactFlightDOMEdge', () => { const serializedContent = await readResult(stream1); - expect(serializedContent.length).toBeLessThan(400); + expect(serializedContent.length).toBeLessThan(410); expect(timesRendered).toBeLessThan(5); const model = await ReactServerDOMClient.createFromReadableStream(stream2, { @@ -296,7 +296,7 @@ describe('ReactFlightDOMEdge', () => { const [stream1, stream2] = passThrough(stream).tee(); const serializedContent = await readResult(stream1); - expect(serializedContent.length).toBeLessThan(400); + expect(serializedContent.length).toBeLessThan(__DEV__ ? 590 : 400); expect(timesRendered).toBeLessThan(5); const model = await ReactServerDOMClient.createFromReadableStream(stream2, { @@ -324,7 +324,7 @@ describe('ReactFlightDOMEdge', () => { , ); const serializedContent = await readResult(stream); - const expectedDebugInfoSize = __DEV__ ? 64 * 20 : 0; + const expectedDebugInfoSize = __DEV__ ? 300 * 20 : 0; expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize); }); @@ -742,10 +742,18 @@ describe('ReactFlightDOMEdge', () => { // We've rendered down to the span. expect(greeting.type).toBe('span'); if (__DEV__) { - const greetInfo = {name: 'Greeting', env: 'Server', owner: null}; + const greetInfo = expect.objectContaining({ + name: 'Greeting', + env: 'Server', + owner: null, + }); expect(lazyWrapper._debugInfo).toEqual([ greetInfo, - {name: 'Container', env: 'Server', owner: greetInfo}, + expect.objectContaining({ + name: 'Container', + env: 'Server', + owner: greetInfo, + }), ]); // The owner that created the span was the outer server component. // We expect the debug info to be referentially equal to the owner. diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4b77895c98fe8..ef3d2dcfb1e1c 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -17,6 +17,7 @@ import { enableTaint, enableRefAsProp, enableServerComponentLogs, + enableOwnerStacks, } from 'shared/ReactFeatureFlags'; import {enableFlightReadableStream} from 'shared/ReactFeatureFlags'; @@ -123,6 +124,98 @@ import binaryToComparableString from 'shared/binaryToComparableString'; import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable'; +// TODO: Make this configurable on the Request. +const externalRegExp = /\/node\_modules\/| \(node\:| node\:|\(\\)/; + +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 initCallIteratorFrame(): string { + // Extract the stack frame of the callIteratorInDEV function. + try { + (callIteratorInDEV: any)({next: 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 = initCallIteratorFrame(); + } + lastFrameIdx = frames.indexOf(callIteratorFrame); + } + } + if (lastFrameIdx !== -1) { + // Cut off everything after our "callComponent" slot since it'll be Flight internals. + frames.length = lastFrameIdx; + } + return frames.filter(isNotExternal).join('\n'); +} + initAsyncDebugInfo(); function patchConsole(consoleInst: typeof console, methodName: string) { @@ -146,10 +239,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) { // Extract the stack. Not all console logs print the full stack but they have at // least the line it was called from. We could optimize transfer by keeping just // one stack frame but keeping it simple for now and include all frames. - let stack = new Error().stack; - if (stack.startsWith('Error: \n')) { - stack = stack.slice(8); - } + let stack = filterDebugStack(new Error('react-stack-top-frame')); const firstLine = stack.indexOf('\n'); if (firstLine === -1) { stack = ''; @@ -621,6 +711,20 @@ function serializeReadableStream( return serializeByValueID(streamTask.id); } +// This indirect exists so we can exclude its stack frame in DEV (and anything below it). +/** @noinline */ +function callIteratorInDEV( + iterator: $AsyncIterator, + progress: ( + entry: + | {done: false, +value: ReactClientValue, ...} + | {done: true, +value: ReactClientValue, ...}, + ) => void, + error: (reason: mixed) => void, +) { + iterator.next().then(progress, error); +} + function serializeAsyncIterable( request: Request, task: Task, @@ -697,7 +801,11 @@ function serializeAsyncIterable( request.pendingChunks++; tryStreamTask(request, streamTask); enqueueFlush(request); - iterator.next().then(progress, error); + if (__DEV__) { + callIteratorInDEV(iterator, progress, error); + } else { + iterator.next().then(progress, error); + } } catch (x) { error(x); return; @@ -731,7 +839,11 @@ function serializeAsyncIterable( } } request.abortListeners.add(error); - iterator.next().then(progress, error); + if (__DEV__) { + callIteratorInDEV(iterator, progress, error); + } else { + iterator.next().then(progress, error); + } return serializeByValueID(streamTask.id); } @@ -809,13 +921,49 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) { return lazyType; } +// This indirect exists so we can exclude its stack frame in DEV (and anything below it). +/** @noinline */ +function callComponentInDEV( + Component: (p: Props, arg: void) => R, + props: Props, + componentDebugInfo: ReactComponentInfo, +): R { + // The secondArg is always undefined in Server Components since refs error early. + const secondArg = undefined; + setCurrentOwner(componentDebugInfo); + try { + if (supportsComponentStorage) { + // Run the component in an Async Context that tracks the current owner. + return componentStorage.run( + componentDebugInfo, + Component, + props, + secondArg, + ); + } else { + return Component(props, secondArg); + } + } finally { + setCurrentOwner(null); + } +} + +// This indirect exists so we can exclude its stack frame in DEV (and anything below it). +/** @noinline */ +function callLazyInitInDEV(lazy: LazyComponent): any { + const payload = lazy._payload; + const init = lazy._init; + return init(payload); +} + function renderFunctionComponent( request: Request, task: Task, key: null | string, Component: (p: Props, arg: void) => any, props: Props, - owner: null | ReactComponentInfo, + owner: null | ReactComponentInfo, // DEV-only + stack: null | string, // DEV-only ): ReactJSONValue { // Reset the task's thenable state before continuing, so that if a later // component suspends we can reuse the same task object. If the same @@ -823,8 +971,6 @@ function renderFunctionComponent( const prevThenableState = task.thenableState; task.thenableState = null; - // The secondArg is always undefined in Server Components since refs error early. - const secondArg = undefined; let result; let componentDebugInfo: ReactComponentInfo; @@ -850,6 +996,9 @@ function renderFunctionComponent( env: request.environmentName, owner: owner, }; + if (enableOwnerStacks) { + (componentDebugInfo: any).stack = stack; + } // We outline this model eagerly so that we can refer to by reference as an owner. // If we had a smarter way to dedupe we might not have to do this if there ends up // being no references to this as an owner. @@ -857,24 +1006,11 @@ function renderFunctionComponent( emitDebugChunk(request, componentDebugID, componentDebugInfo); } prepareToUseHooksForComponent(prevThenableState, componentDebugInfo); - setCurrentOwner(componentDebugInfo); - try { - if (supportsComponentStorage) { - // Run the component in an Async Context that tracks the current owner. - result = componentStorage.run( - componentDebugInfo, - Component, - props, - secondArg, - ); - } else { - result = Component(props, secondArg); - } - } finally { - setCurrentOwner(null); - } + result = callComponentInDEV(Component, props, componentDebugInfo); } else { prepareToUseHooksForComponent(prevThenableState, null); + // The secondArg is always undefined in Server Components since refs error early. + const secondArg = undefined; result = Component(props, secondArg); } if (typeof result === 'object' && result !== null) { @@ -1093,6 +1229,7 @@ function renderClientElement( key: null | string, props: any, owner: null | ReactComponentInfo, // DEV-only + stack: null | string, // DEV-only ): ReactJSONValue { // We prepend the terminal client element that actually gets serialized with // the keys of any Server Components which are not serialized. @@ -1103,7 +1240,9 @@ function renderClientElement( key = keyPath + ',' + key; } const element = __DEV__ - ? [REACT_ELEMENT_TYPE, type, key, props, owner] + ? enableOwnerStacks + ? [REACT_ELEMENT_TYPE, type, key, props, owner, stack] + : [REACT_ELEMENT_TYPE, type, key, props, owner] : [REACT_ELEMENT_TYPE, type, key, props]; if (task.implicitSlot && key !== null) { // The root Server Component had no key so it was in an implicit slot. @@ -1151,6 +1290,7 @@ function renderElement( ref: mixed, props: any, owner: null | ReactComponentInfo, // DEV only + stack: null | string, // DEV only ): ReactJSONValue { if (ref !== null && ref !== undefined) { // When the ref moves to the regular props object this will implicitly @@ -1171,13 +1311,21 @@ function renderElement( if (typeof type === 'function') { if (isClientReference(type) || isTemporaryReference(type)) { // This is a reference to a Client Component. - return renderClientElement(task, type, key, props, owner); + return renderClientElement(task, type, key, props, owner, stack); } // This is a Server Component. - return renderFunctionComponent(request, task, key, type, props, owner); + return renderFunctionComponent( + request, + task, + key, + type, + props, + owner, + stack, + ); } else if (typeof type === 'string') { // This is a host element. E.g. HTML. - return renderClientElement(task, type, key, props, owner); + return renderClientElement(task, type, key, props, owner, stack); } else if (typeof type === 'symbol') { if (type === REACT_FRAGMENT_TYPE && key === null) { // For key-less fragments, we add a small optimization to avoid serializing @@ -1198,17 +1346,22 @@ function renderElement( } // This might be a built-in React component. We'll let the client decide. // Any built-in works as long as its props are serializable. - return renderClientElement(task, type, key, props, owner); + return renderClientElement(task, type, key, props, owner, stack); } else if (type != null && typeof type === 'object') { if (isClientReference(type)) { // This is a reference to a Client Component. - return renderClientElement(task, type, key, props, owner); + return renderClientElement(task, type, key, props, owner, stack); } switch (type.$$typeof) { case REACT_LAZY_TYPE: { - const payload = type._payload; - const init = type._init; - const wrappedType = init(payload); + let wrappedType; + if (__DEV__) { + wrappedType = callLazyInitInDEV(type); + } else { + const payload = type._payload; + const init = type._init; + wrappedType = init(payload); + } return renderElement( request, task, @@ -1217,6 +1370,7 @@ function renderElement( ref, props, owner, + stack, ); } case REACT_FORWARD_REF_TYPE: { @@ -1227,10 +1381,20 @@ function renderElement( type.render, props, owner, + stack, ); } case REACT_MEMO_TYPE: { - return renderElement(request, task, type.type, key, ref, props, owner); + return renderElement( + request, + task, + type.type, + key, + ref, + props, + owner, + stack, + ); } } } @@ -1822,6 +1986,9 @@ function renderModelDestructive( ref, props, __DEV__ ? element._owner : null, + __DEV__ && enableOwnerStacks + ? filterDebugStack(element._debugStack) + : null, ); } case REACT_LAZY_TYPE: { @@ -1830,9 +1997,14 @@ function renderModelDestructive( task.thenableState = null; const lazy: LazyComponent = (value: any); - const payload = lazy._payload; - const init = lazy._init; - const resolvedModel = init(payload); + let resolvedModel; + if (__DEV__) { + resolvedModel = callLazyInitInDEV(lazy); + } else { + const payload = lazy._payload; + const init = lazy._init; + resolvedModel = init(payload); + } if (__DEV__) { const debugInfo: ?ReactDebugInfo = lazy._debugInfo; if (debugInfo) { diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index fad4d42bcc50d..f799fd7b16b49 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -13,6 +13,7 @@ import { getIteratorFn, REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, + REACT_LAZY_TYPE, } from 'shared/ReactSymbols'; import {checkKeyStringCoercion} from 'shared/CheckStringCoercion'; import isValidElementType from 'shared/isValidElementType'; @@ -23,6 +24,7 @@ import { disableStringRefs, disableDefaultPropsExceptForClasses, enableFastJSX, + enableOwnerStacks, } from 'shared/ReactFeatureFlags'; import {checkPropStringCoercion} from 'shared/CheckStringCoercion'; import {ClassComponent} from 'react-reconciler/src/ReactWorkTags'; @@ -30,6 +32,34 @@ import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFrom const REACT_CLIENT_REFERENCE = Symbol.for('react.client.reference'); +const createTask = + // eslint-disable-next-line react-internal/no-production-logging + __DEV__ && enableOwnerStacks && console.createTask + ? // eslint-disable-next-line react-internal/no-production-logging + console.createTask + : () => null; + +function getTaskName(type) { + if (type === REACT_FRAGMENT_TYPE) { + return '<>'; + } + if ( + typeof type === 'object' && + type !== null && + type.$$typeof === REACT_LAZY_TYPE + ) { + // We don't want to eagerly initialize the initializer in DEV mode so we can't + // call it to extract the type so we don't know the type of this component. + return '<...>'; + } + try { + const name = getComponentNameFromType(type); + return name ? '<' + name + '>' : '<...>'; + } catch (x) { + return '<...>'; + } +} + function getOwner() { if (__DEV__ || !disableStringRefs) { const dispatcher = ReactSharedInternals.A; @@ -194,7 +224,17 @@ function elementRefGetterWithDeprecationWarning() { * indicating filename, line number, and/or other information. * @internal */ -function ReactElement(type, key, _ref, self, source, owner, props) { +function ReactElement( + type, + key, + _ref, + self, + source, + owner, + props, + debugStack, + debugTask, +) { let ref; if (enableRefAsProp) { // When enableRefAsProp is on, ignore whatever was passed as the ref @@ -311,6 +351,20 @@ function ReactElement(type, key, _ref, self, source, owner, props) { writable: true, value: null, }); + if (enableOwnerStacks) { + Object.defineProperty(element, '_debugStack', { + configurable: false, + enumerable: false, + writable: true, + value: debugStack, + }); + Object.defineProperty(element, '_debugTask', { + configurable: false, + enumerable: false, + writable: true, + value: debugTask, + }); + } if (Object.freeze) { Object.freeze(element.props); Object.freeze(element); @@ -404,7 +458,17 @@ export function jsxProd(type, config, maybeKey) { } } - return ReactElement(type, key, ref, undefined, undefined, getOwner(), props); + return ReactElement( + type, + key, + ref, + undefined, + undefined, + getOwner(), + props, + undefined, + undefined, + ); } // While `jsxDEV` should never be called when running in production, we do @@ -652,6 +716,8 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren, source, self) { source, getOwner(), props, + __DEV__ && enableOwnerStacks ? Error('react-stack-top-frame') : undefined, + __DEV__ && enableOwnerStacks ? createTask(getTaskName(type)) : undefined, ); if (type === REACT_FRAGMENT_TYPE) { @@ -842,6 +908,8 @@ export function createElement(type, config, children) { undefined, getOwner(), props, + __DEV__ && enableOwnerStacks ? Error('react-stack-top-frame') : undefined, + __DEV__ && enableOwnerStacks ? createTask(getTaskName(type)) : undefined, ); if (type === REACT_FRAGMENT_TYPE) { @@ -862,6 +930,8 @@ export function cloneAndReplaceKey(oldElement, newKey) { undefined, !__DEV__ && disableStringRefs ? undefined : oldElement._owner, oldElement.props, + __DEV__ && enableOwnerStacks ? oldElement._debugStack : undefined, + __DEV__ && enableOwnerStacks ? oldElement._debugTask : undefined, ); } @@ -973,6 +1043,8 @@ export function cloneElement(element, config, children) { undefined, owner, props, + __DEV__ && enableOwnerStacks ? element._debugStack : undefined, + __DEV__ && enableOwnerStacks ? element._debugTask : undefined, ); for (let i = 2; i < arguments.length; i++) { diff --git a/packages/shared/ReactElementType.js b/packages/shared/ReactElementType.js index 86b74aa00c1bd..1ae3ead9cb426 100644 --- a/packages/shared/ReactElementType.js +++ b/packages/shared/ReactElementType.js @@ -7,6 +7,12 @@ * @flow */ +import type {ReactDebugInfo} from './ReactTypes'; + +interface ConsoleTask { + run(f: () => T): T; +} + export type ReactElement = { $$typeof: any, type: any, @@ -18,4 +24,7 @@ export type ReactElement = { // __DEV__ _store: {validated: boolean, ...}, + _debugInfo: null | ReactDebugInfo, + _debugStack: Error, + _debugTask: null | ConsoleTask, }; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 1c2ea36054676..e0140ac53533a 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -182,6 +182,7 @@ export type ReactComponentInfo = { +name?: string, +env?: string, +owner?: null | ReactComponentInfo, + +stack?: null | string, }; export type ReactAsyncInfo = { diff --git a/scripts/error-codes/transform-error-messages.js b/scripts/error-codes/transform-error-messages.js index 234224b89b1c1..56c831f9ae7dd 100644 --- a/scripts/error-codes/transform-error-messages.js +++ b/scripts/error-codes/transform-error-messages.js @@ -49,6 +49,11 @@ module.exports = function (babel) { errorMsgExpressions ); + if (errorMsgLiteral === 'react-stack-top-frame') { + // This is a special case for generating stack traces. + return; + } + let prodErrorId = errorMap[errorMsgLiteral]; if (prodErrorId === undefined) { // There is no error code for this message. Add an inline comment diff --git a/scripts/eslint-rules/prod-error-codes.js b/scripts/eslint-rules/prod-error-codes.js index 3d99b8c1feada..9177cae67e465 100644 --- a/scripts/eslint-rules/prod-error-codes.js +++ b/scripts/eslint-rules/prod-error-codes.js @@ -50,6 +50,10 @@ module.exports = { return; } const errorMessage = nodeToErrorTemplate(errorMessageNode); + if (errorMessage === 'react-stack-top-frame') { + // This is a special case for generating stack traces. + return; + } if (errorMessages.has(errorMessage)) { return; } diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 5aa238804b680..aa1f051ac4922 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -292,9 +292,14 @@ function lazyRequireFunctionExports(moduleName) { // If this export is a function, return a wrapper function that lazily // requires the implementation from the current module cache. if (typeof originalModule[prop] === 'function') { - return function () { + const wrapper = function () { return jest.requireActual(moduleName)[prop].apply(this, arguments); }; + // We use this to trick the filtering of Flight to exclude this frame. + Object.defineProperty(wrapper, 'name', { + value: '()', + }); + return wrapper; } else { return originalModule[prop]; }