diff --git a/packages/shared/ReactComponentStackFrame.js b/packages/shared/ReactComponentStackFrame.js index 9df78e0fae46..58455f900bdd 100644 --- a/packages/shared/ReactComponentStackFrame.js +++ b/packages/shared/ReactComponentStackFrame.js @@ -60,6 +60,97 @@ if (__DEV__) { componentFrameCache = new PossiblyWeakMap(); } +/** + * A `prepareStackTrace` method that roughly mimicks how V8 generates stack + * traces. This is used to ensure a consistent stack trace format for different + * VMs that support V8's stack trace API: https://v8.dev/docs/stack-trace-api + * + * For example, the Hermes VM currently truncates stack traces in the middle + * (https://github.com/facebook/hermes/blob/d8ce9fccaf2ff964f09fecc033c480fb23f65a4c/lib/VM/JSError.cpp#L701-L705) + * which ends up break component trace generation for cases where React is + * running in Hermes (e.g. React Native). This method will ensure that the + * truncation of traces always happens at the bottom to match what + * `describeNativeComponentFrame` expects. + * + * Implementation here is roughly based off of description in: + * https://v8.dev/docs/stack-trace-api as well viewing sample stack traces + * from V8. + */ +export function prepareStackTrace( + err: Error, + /* global CallSite */ + callsites: Array, +): string { + let trace = `${err.name}: ${err.message}`; + for ( + let i = 0; + i < Math.min(callsites.length, Error.stackTraceLimit || 100); + i++ + ) { + const callsite = callsites[i]; + trace += '\n at '; + + // The following methods should be available for VM versions that support + // async stack traces. + // $FlowFixMe[prop-missing] + // $FlowFixMe[incompatible-use] + if (callsite.isPromiseAll()) { + // $FlowFixMe[prop-missing] + return `${trace} async Promise.all (index ${callsite.getPromiseIndex()})`; + } + + let caller = ''; + const typeName = callsite.getTypeName(); + if (callsite.isConstructor()) { + caller += 'new '; + } else if (typeName != null && !callsite.isToplevel()) { + caller += `${typeName}.`; + } + + const functionName = callsite.getFunctionName(); + const methodName = callsite.getMethodName(); + if ( + functionName != null && + methodName != null && + !callsite.isConstructor() && + functionName !== methodName + ) { + caller += `${functionName} [as ${methodName}]`; + } else if (functionName != null) { + caller += functionName; + } else if (methodName != null) { + caller += methodName; + } else if (caller !== '') { + caller += ''; + } + + const evalOrigin = callsite.getEvalOrigin(); + let location = ''; + if (callsite.isEval() && evalOrigin != null) { + location += `${evalOrigin.toString()}, `; + } + + const filename = callsite.getFileName(); + if (callsite.isNative()) { + location += 'native'; + } else if (filename != null) { + location += `${filename}`; + } else { + location += ''; + } + const line = callsite.getLineNumber(); + if (line != null) { + location += `:${line}`; + const col = callsite.getColumnNumber(); + if (col != null) { + location += `:${col}`; + } + } + trace += caller !== '' ? `${caller} (${location})` : location; + } + return trace; +} + export function describeNativeComponentFrame( fn: Function, construct: boolean, @@ -80,8 +171,7 @@ export function describeNativeComponentFrame( reentry = true; const previousPrepareStackTrace = Error.prepareStackTrace; - // $FlowFixMe[incompatible-type] It does accept undefined. - Error.prepareStackTrace = undefined; + Error.prepareStackTrace = prepareStackTrace; let previousDispatcher; if (__DEV__) { previousDispatcher = ReactCurrentDispatcher.current; diff --git a/packages/shared/__tests__/prepareStackTrace-test.js b/packages/shared/__tests__/prepareStackTrace-test.js new file mode 100644 index 000000000000..cccd0ca426e1 --- /dev/null +++ b/packages/shared/__tests__/prepareStackTrace-test.js @@ -0,0 +1,131 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +import {prepareStackTrace} from '../ReactComponentStackFrame'; + +describe('Custom stack trace generation', () => { + function normalizeTrace(stackTrace: string): string { + return ( + stackTrace + .replace(/\:\d+?(\:\d+)/g, ':*') + // NodeJS doesn't output `process` module type name, which appears to + // be used heavily as part of its async/await regenerator + // implementation... + .replace('process.', '') + ); + } + + beforeEach(() => { + Error.prepareStackTrace = undefined; + }); + + function compareCustomTraceWithV8(fn: () => void) { + try { + fn(); + } catch (controlError) { + const controlStackTrace = normalizeTrace(controlError.stack); + try { + Error.prepareStackTrace = prepareStackTrace; + fn(); + } catch (sampleError) { + expect(normalizeTrace(sampleError.stack)).toBe(controlStackTrace); + } finally { + Error.prepareStackTrace = undefined; + } + } + } + + /** + * Stack trace should look something like: + * Error: Test Error Message + * at bar (:*) + * at foo (:*) + * at :* + * ... + */ + it('should generate the same stack trace as V8 for simple function calls', () => { + function foo() { + bar(); + } + function bar() { + throw new Error('Test Error Message'); + } + compareCustomTraceWithV8(() => foo()); + }); + + /** + * Stack trace should look something like: + * Error: Test Error Message + * at Biz.qux (:*) + * at Object.bazFunction [as baz] (:*) + * at Object.bar (:*) + * at :* + * ... + */ + it('should generate the same stack trace as V8 for namespaced function calls', () => { + const foo = { + bar() { + this.baz(); + }, + baz: function bazFunction() { + new Biz().qux(); + }, + }; + class Biz { + qux() { + throw new Error('Test Error Message'); + } + } + compareCustomTraceWithV8(() => foo.bar()); + }); + + /** + * Stack trace should look something like: + * Error: Test Error Message + * at Bar.quxFunction [as qux] (:*) + * at new Bar (:*) + * at Bar.baz (:*) + * at :* + * ... + */ + it('should generate the same stack trace as V8 for constructor calls', () => { + const foo = { + bar: class Bar { + static baz() { + return new foo.bar(); + } + constructor() { + this.qux(); + } + qux = function quxFunction() { + throw new Error('Test Error Message'); + }; + }, + }; + compareCustomTraceWithV8(() => foo.bar.baz()); + }); + + /** + * Stack trace should look something like: + * Error: Test Error Message + * at eval (eval at foo (:*), :*) + * at foo (:*) + * at :* + * ... + */ + it('should generate the same stack trace as V8 for evals', () => { + compareCustomTraceWithV8(function foo() { + // This is for testing eval stack traces + // eslint-disable-next-line no-eval + eval("throw new Error('Test Error Message');"); + }); + }); +});